tanstack-router

Type-safe, file-based React routing with route loaders, TanStack Query integration, and 20 documented error preventions. File-based routing with auto-generated types, typed params, and search param validation via Zod adapter Route-level data loading with loaders, error boundaries, and beforeLoad hooks for authentication and redirects Virtual file routes for programmatic route configuration when file-based conventions don't fit Prevents critical issues including Vite plugin ordering, devtools resolution, SSR streaming crashes, Docker prerender hangs, and param parsing bugs after navigation

INSTALLATION
npx skills add https://github.com/jezweb/claude-skills --skill tanstack-router
Run in your project or agent environment. Adjust flags if your CLI version differs.

SKILL.md

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.

BrowserAct

Let your agent run on any real-world website

Bypass CAPTCHA & anti-bot for free. Start local, scale to cloud.

Explore BrowserAct Skills →

Stop writing automation&scrapers

Install the CLI. Run your first Skill in 30 seconds. Scale when you're ready.

Start free
free · no credit card