TanStack Router
Type-safe, file-based routing for React SPAs with route-level data loading and TanStack Query integration
Quick Start
Last Updated: 2026-01-09 Version: @tanstack/react-router@1.146.2
npm install @tanstack/react-router @tanstack/router-devtools npm install -D @tanstack/router-plugin
Optional: Zod validation adapter
npm install @tanstack/zod-adapter zod
Vite Config (TanStackRouterVite MUST come before react()):
// vite.config.ts import { TanStackRouterVite } from '@tanstack/router-plugin/vite'
export default defineConfig({ plugins: [TanStackRouterVite(), react()], // Order matters! })
File Structure:
src/routes/ ├── __root.tsx → createRootRoute() with <Outlet /> ├── index.tsx → createFileRoute('/') └── posts.$postId.tsx → createFileRoute('/posts/$postId')
App Setup:
import { createRouter, RouterProvider } from '@tanstack/react-router' import { routeTree } from './routeTree.gen' // Auto-generated by plugin
const router = createRouter({ routeTree }) <RouterProvider router={router} />
Core Patterns
Type-Safe Navigation (routes auto-complete, params typed):
<Link to="/posts/$postId" params={{ postId: '123' }} /> <Link to="/invalid" /> // ❌ TypeScript error
Route Loaders (data fetching before render):
export const Route = createFileRoute('/posts/$postId')({ loader: async ({ params }) => ({ post: await fetchPost(params.postId) }), component: ({ useLoaderData }) => { const { post } = useLoaderData() // Fully typed! return <h1>{post.title}</h1> }, })
TanStack Query Integration (prefetch + cache):
const postOpts = (id: string) => queryOptions({ queryKey: ['posts', id], queryFn: () => fetchPost(id), })
export const Route = createFileRoute('/posts/$postId')({ loader: ({ context: { queryClient }, params }) => queryClient.ensureQueryData(postOpts(params.postId)), component: () => { const { postId } = Route.useParams() const { data } = useQuery(postOpts(postId)) return <h1>{data.title}</h1> }, })
Virtual File Routes (v1.140+)
Programmatic route configuration when file-based conventions don't fit your needs:
Install: npm install @tanstack/virtual-file-routes
Vite Config:
import { tanstackRouter } from '@tanstack/router-plugin/vite'
export default defineConfig({ plugins: [ tanstackRouter({ target: 'react', virtualRouteConfig: './routes.ts', // Point to your routes file }), react(), ], })
routes.ts (define routes programmatically):
import { rootRoute, route, index, layout, physical } from '@tanstack/virtual-file-routes'
export const routes = rootRoute('root.tsx', [ index('home.tsx'), route('/posts', 'posts/posts.tsx', [ index('posts/posts-home.tsx'), route('$postId', 'posts/posts-detail.tsx'), ]), layout('first', 'layout/first-layout.tsx', [ route('/nested', 'nested.tsx'), ]), physical('/classic', 'file-based-subtree'), // Mix with file-based ])
Use Cases: Custom route organization, mixing file-based and code-based, complex nested layouts.
Search Params Validation (Zod Adapter)
Type-safe URL search params with runtime validation:
Basic Pattern (inline validation):
import { z } from 'zod'
export const Route = createFileRoute('/products')({ validateSearch: (search) => z.object({ page: z.number().catch(1), filter: z.string().catch(''), sort: z.enum(['newest', 'oldest', 'price']).catch('newest'), }).parse(search), })
Recommended Pattern (Zod adapter with fallbacks):
import { zodValidator, fallback } from '@tanstack/zod-adapter' import { z } from 'zod'
const searchSchema = z.object({ query: z.string().min(1).max(100), page: fallback(z.number().int().positive(), 1), sortBy: z.enum(['name', 'date', 'relevance']).optional(), })
export const Route = createFileRoute('/search')({ validateSearch: zodValidator(searchSchema), // Type-safe: Route.useSearch() returns typed params })
Why .catch() over .default() : Use .catch() to silently fix malformed params. Use .default()
- errorComponent to show validation errors.
Error Boundaries
Handle errors at route level with typed error components:
Route-Level Error Handling:
export const Route = createFileRoute('/posts/$postId')({ loader: async ({ params }) => { const post = await fetchPost(params.postId) if (!post) throw new Error('Post not found') return { post } }, errorComponent: ({ error, reset }) => ( <div> <p>Error: {error.message}</p> <button onClick={reset}>Retry</button> </div> ), })
Default Error Component (global fallback):
const router = createRouter({ routeTree, defaultErrorComponent: ({ error }) => ( <div className="error-page"> <h1>Something went wrong</h1> <p>{error.message}</p> </div> ), })
Not Found Handling:
export const Route = createFileRoute('/posts/$postId')({ notFoundComponent: () => <div>Post not found</div>, })
Authentication with beforeLoad
Protect routes before they load (no flash of protected content):
Single Route Protection:
import { redirect } from '@tanstack/react-router'
export const Route = createFileRoute('/dashboard')({ beforeLoad: async ({ context }) => { if (!context.auth.isAuthenticated) { throw redirect({ to: '/login', search: { redirect: location.pathname }, // Save for post-login }) } }, })
Protect Multiple Routes (layout route pattern):
// routes/(authenticated)/route.tsx - protects all children export const Route = createFileRoute('/(authenticated)')({ beforeLoad: async ({ context }) => { if (!context.auth.isAuthenticated) { throw redirect({ to: '/login' }) } }, })
Passing Auth Context (from React hooks):
// main.tsx - pass auth state to router function App() { const auth = useAuth() // Your auth hook
return ( <RouterProvider router={router} context={{ auth }} // Available in beforeLoad /> ) }
Known Issues Prevention
This skill prevents 20 documented issues:
Issue #1: Devtools Dependency Resolution
-
Error: Build fails with @tanstack/router-devtools-core not found
-
Fix: npm install @tanstack/router-devtools
Issue #2: Vite Plugin Order (CRITICAL)
-
Error: Routes not auto-generated, routeTree.gen.ts missing
-
Fix: TanStackRouterVite MUST come before react() in plugins array
-
Why: Plugin processes route files before React compilation
Issue #3: Type Registration Missing
-
Error: <Link to="..."> not typed, no autocomplete
-
Fix: Import routeTree from ./routeTree.gen in main.tsx to register types
Issue #4: Loader Not Running
-
Error: Loader function not called on navigation
-
Fix: Ensure route exports Route constant: export const Route = createFileRoute('/path')({ loader: ... })
Issue #5: Memory Leak with TanStack Form (FIXED)
-
Error: Production crashes when using TanStack Form + Router
-
Source: GitHub Issue #5734 (closed Jan 5, 2026)
-
Resolution: Fixed in latest versions of @tanstack/form and @tanstack/react-start. Update both packages to resolve.
Issue #6: Virtual Routes Index/Layout Conflict
-
Error: route.tsx and index.tsx conflict when using physical() in virtual routing
-
Source: GitHub Issue #5421
-
Fix: Use pathless route instead: _layout.tsx
- _layout.index.tsx
Issue #7: Search Params Type Inference
-
Error: Type inference not working with zodSearchValidator
-
Source: GitHub Issue #3100 (regression since v1.81.5)
-
Fix: Use zodValidator from @tanstack/zod-adapter instead
Issue #8: TanStack Start Validators on Reload
-
Error: validateSearch not working on page reload in TanStack Start
-
Source: GitHub Issue #3711
-
Note: Works on client-side navigation, fails on direct page load
Issue #9: Server Function Validation Errors Lose Structure
Error: inputValidator Zod errors stringified, losing structure on client Source: GitHub Issue #6428 Why It Happens: TanStack Start server function error serialization converts Zod issues array to JSON string in error.message , making it unusable without manual parsing.
Prevention:
// Server function with input validation export const myFn = createServerFn({ method: 'POST' }) .inputValidator(z.object({ name: z.string().min(2), age: z.number().min(18), })) .handler(async ({ data }) => data)
// Client: Workaround to parse stringified issues try { await mutation.mutate({ data: invalidData }) } catch (error) { if (error.message.startsWith('[')) { const issues = JSON.parse(error.message) // Now can use structured error data issues.forEach(issue => { console.log(issue.path, issue.message) }) } }
Official Status: Known issue, tracking PR for fix
Issue #10: useParams({ strict: false }) Returns Unparsed Values
Error: Params typed as parsed but returned as strings after navigation Source: GitHub Issue #6385 Why It Happens: In v1.147.3+, match.params is no longer parsed when using strict: false . First render works correctly, but after navigation values are stored as strings instead of parsed types.
Prevention:
// Route with param parsing export const Route = createFileRoute('/posts/$postId')({ params: { parse: (params) => ({ postId: z.coerce.number().parse(params.postId), }), }, })
// Component: Use strict mode (default) for parsed params function Component() { const { postId } = useParams() // ✓ Parsed as number // const { postId } = useParams({ strict: false }) // ✗ String!
// Or manually parse when using strict: false const params = useParams({ strict: false }) const postId = Number(params.postId) }
Official Status: Known issue, workaround required
Issue #11: Pathless Route notFoundComponent Not Rendering
Error: notFoundComponent on pathless layout routes ignored Source: GitHub Issue #6351, GitHub Issue #4065 Why It Happens: Pathless routes (e.g., routes/(authenticated)/route.tsx ) don't render their notFoundComponent . Instead, the defaultNotFoundComponent from router config is triggered. This has been broken since April 2025.
Prevention:
// ✗ Doesn't work: notFoundComponent on pathless layout export const Route = createFileRoute('/(authenticated)')({ beforeLoad: ({ context }) => { if (!context.auth) throw redirect({ to: '/login' }) }, notFoundComponent: () => <div>Protected 404</div>, // Not rendered! })
// ✓ Works: Define on child routes instead export const Route = createFileRoute('/(authenticated)/dashboard')({ notFoundComponent: () => <div>Protected 404</div>, })
Official Status: Known issue, workaround required
Issue #12: Aborted Loader Renders errorComponent with Undefined Error
Error: Rapid navigation aborts previous loader and renders errorComponent with undefined error Source: GitHub Issue #6388 Why It Happens: Side effect introduced after PR #4570. When user rapidly navigates (e.g., clicking through list items), aborted fetch requests trigger errorComponent without passing the abort error.
Prevention:
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params, abortController }) => {
await fetch(/api/posts/${params.postId}, {
signal: abortController.signal,
})
},
errorComponent: ({ error, reset }) => {
// Check for undefined error (aborted request)
if (!error) {
return null // Or show loading state
}
return <div>Error: {error.message}</div>
},
})
Official Status: Known issue, workaround required
Issue #13: Vitest Cannot Read Properties of Null (useState)
Error: Cannot read properties of null (reading 'useState') when running tests with Vitest Source: GitHub Issue #6262, PR #6074 Why It Happens: TanStack Start's tanstackStart() plugin conflicts with Vitest's React hooks rendering. This is a known duplicate issue with a PR in progress.
Prevention:
// Temporary workaround: Comment out tanstackStart() for tests // vite.config.ts export default defineConfig({ plugins: [ // tanstackStart(), // Disable for tests react(), ], test: { environment: 'jsdom' }, })
Official Status: PR #6074 in progress to fix
Issue #14: Throwing Error in Streaming SSR Loader Crashes Dev Server
Error: Dev server crashes when route loader throws error without awaiting (using void instead of await ) Source: GitHub Issue #6200 Why It Happens: SSR streaming mode can't handle unawaited promise rejections. The error escapes the loader context and crashes the worker process.
Prevention:
// ✗ Wrong: void + throw crashes dev server export const Route = createFileRoute('/posts')({ loader: async () => { void fetch('/api/posts').then(r => { throw new Error('boom') // Crashes! }) }, })
// ✓ Correct: Always await or catch export const Route = createFileRoute('/posts')({ loader: async () => { try { const data = await fetch('/api/posts') return data } catch (error) { throw error // Caught by errorComponent } }, })
Official Status: Known issue, workaround required
Issue #15: Prerender Hangs Indefinitely if Filter Returns Zero Results
Error: Build step hangs when prerender.filter returns zero routes Source: GitHub Issue #6425 Why It Happens: TanStack Start prerendering doesn't handle empty route sets gracefully - it waits indefinitely for routes that never come.
Prevention:
// ✗ Wrong: Empty filter causes hang tanstackStart({ prerender: { enabled: true, filter: (route) => false, // No routes → hangs! }, })
// ✓ Correct: Ensure at least one route or disable tanstackStart({ prerender: { enabled: true, filter: (route) => route.path === '/' || route.path.startsWith('/posts'), }, })
// Or temporarily disable tanstackStart({ prerender: { enabled: false }, })
Official Status: Known issue, workaround required
Issue #16: Prerendering Does Not Work in Docker
Error: Build fails in Docker with "Unable to connect" during prerender step Source: GitHub Issue #6275, PR #6305 Why It Happens: Vite preview server used for prerendering is not accessible in Docker environment.
Prevention:
// vite.config.ts - Make preview server accessible in Docker export default defineConfig({ preview: { host: true, // Bind to 0.0.0.0 instead of localhost }, plugins: [ devtools(), // nitro({ preset: "bun" }), // Remove temporarily if issues persist tanstackStart(), react(), ], })
Official Status: PR #6305 in progress
Issue #17: Route Head Function Executes Before Loader Finishes
Error: Meta tags generated with incomplete data when head() runs before loader()
Source: GitHub Issue #6221 Why It Happens: The head() function can execute before the route loader() finishes, causing meta tags to use placeholder or undefined data.
Prevention:
// ✗ Wrong: loaderData may not be available yet export const Route = createFileRoute('/posts/$postId')({ loader: async ({ params }) => { const post = await fetchPost(params.postId) return { post } }, head: ({ loaderData }) => ({ meta: [ { title: loaderData.post.title }, // May be undefined! ], }), })
// ✓ Correct: Explicitly await if needed export const Route = createFileRoute('/posts/$postId')({ loader: async ({ params }) => { const post = await fetchPost(params.postId) return { post } }, head: async ({ loaderData }) => { await loaderData // Ensure loaded return { meta: [{ title: loaderData.post.title }], } }, })
Official Status: Known issue, workaround required
Issue #18: Virtual Routes Don't Support Manual Lazy Loading (Community-sourced)
Error: createLazyFileRoute automatically replaced with createFileRoute in virtual routes Source: GitHub Issue #6396 Why It Happens: Virtual file routes are designed for automatic code splitting only. Manual lazy routes are not supported - the plugin silently replaces them.
Prevention:
// Virtual routes: Use automatic code splitting // vite.config.ts tanstackRouter({ target: 'react', virtualRouteConfig: './routes.ts', autoCodeSplitting: true, // Use automatic splitting })
// Don't use createLazyFileRoute in virtual routes // It will be replaced with createFileRoute automatically
Official Status: By design (documented behavior)
Issue #19: NavigateOptions Type Safety Inconsistency (Community-sourced)
Error: NavigateOptions type doesn't enforce required params like useNavigate() does Source: TkDodo's Blog: The Beauty of TanStack Router Why It Happens: Type definitions differ between runtime hook and type helper. NavigateOptions is less strict.
Prevention:
// ✗ Wrong: NavigateOptions doesn't catch missing params const options: NavigateOptions = { to: '/posts/$postId', // No TS error, but params required! }
// ✓ Correct: Use useNavigate() return type const navigate = useNavigate() type NavigateFn = typeof navigate // Now type-safe across all usages
Verified: Cross-referenced with TanStack Query maintainer analysis
Issue #20: Missing Leading Slash in Route Paths (Community-sourced)
Error: Routes fail to match when path defined without leading slash Source: Official Debugging Guide Why It Happens: Very common beginner mistake - using 'about' instead of '/about' causes route matching failures.
Prevention:
// ✗ Wrong: Missing leading slash export const Route = createFileRoute('about')({ /* ... */ })
// ✓ Correct: Always start with / export const Route = createFileRoute('/about')({ /* ... */ })
Verified: Official documentation, common debugging issue
Cloudflare Workers Integration
Vite Config (add @cloudflare/vite-plugin):
import { cloudflare } from '@cloudflare/vite-plugin'
export default defineConfig({ plugins: [TanStackRouterVite(), react(), cloudflare()], })
API Routes Pattern (fetch from Workers backend):
// Worker: functions/api/posts.ts export async function onRequestGet({ env }) { const { results } = await env.DB.prepare('SELECT * FROM posts').all() return Response.json(results) }
// Router: src/routes/posts.tsx export const Route = createFileRoute('/posts')({ loader: async () => fetch('/api/posts').then(r => r.json()), })
Related Skills: tanstack-query (data fetching), react-hook-form-zod (form validation), cloudflare-worker-base (API backend), tailwind-v4-shadcn (UI)
Related Packages: @tanstack/zod-adapter (search validation), @tanstack/virtual-file-routes (programmatic routes)
Last verified: 2026-01-20 | Skill version: 2.0.0 | Changes: Added 12 new issues from community research (inputValidator structure loss, useParams parsing bug, pathless notFoundComponent, aborted loader errors, Vitest conflicts, SSR streaming crashes, Docker prerender issues, head/loader timing, virtual routes lazy loading limitation, NavigateOptions type inconsistency, leading slash common mistake). Increased error prevention from 8 to 20 documented issues.