home / skills / jezweb / claude-skills / tanstack-router

tanstack-router skill

/skills/tanstack-router

This skill helps you implement type-safe, file-based routing with TanStack Router, including loaders, query integration, and robust error handling.

npx playbooks add skill jezweb/claude-skills --skill tanstack-router

Review the files below or copy the command above to add this skill to your agents.

Files (8)
SKILL.md
20.7 KB
---
name: tanstack-router
description: |
  Build type-safe, file-based React routing with TanStack Router. Supports client-side navigation, route loaders, and TanStack Query integration. Prevents 20 documented errors including validation structure loss, param parsing bugs, and SSR streaming crashes.

  Use when implementing file-based routing patterns, building SPAs with TypeScript routing, or troubleshooting devtools dependency errors, type safety issues, Vite bundling problems, or Docker deployment issues.
user-invocable: true
---

# 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/[email protected]

```bash
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()):
```typescript
// 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**:
```typescript
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):
```typescript
<Link to="/posts/$postId" params={{ postId: '123' }} />
<Link to="/invalid" /> // ❌ TypeScript error
```

**Route Loaders** (data fetching before render):
```typescript
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):
```typescript
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**:
```typescript
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):
```typescript
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):
```typescript
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):
```typescript
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**:
```typescript
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):
```typescript
const router = createRouter({
  routeTree,
  defaultErrorComponent: ({ error }) => (
    <div className="error-page">
      <h1>Something went wrong</h1>
      <p>{error.message}</p>
    </div>
  ),
})
```

**Not Found Handling**:
```typescript
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**:
```typescript
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):
```typescript
// 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):
```typescript
// 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](https://github.com/TanStack/router/issues/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**:
```typescript
// 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](https://github.com/TanStack/router/issues/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**:
```typescript
// 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](https://github.com/TanStack/router/issues/6351), [GitHub Issue #4065](https://github.com/TanStack/router/issues/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**:
```typescript
// ✗ 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](https://github.com/TanStack/router/issues/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**:
```typescript
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](https://github.com/TanStack/router/issues/6262), [PR #6074](https://github.com/TanStack/router/pull/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**:
```typescript
// 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](https://github.com/TanStack/router/issues/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**:
```typescript
// ✗ 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](https://github.com/TanStack/router/issues/6425)
**Why It Happens**: TanStack Start prerendering doesn't handle empty route sets gracefully - it waits indefinitely for routes that never come.

**Prevention**:
```typescript
// ✗ 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](https://github.com/TanStack/router/issues/6275), [PR #6305](https://github.com/TanStack/router/pull/6305)
**Why It Happens**: Vite preview server used for prerendering is not accessible in Docker environment.

**Prevention**:
```typescript
// 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](https://github.com/TanStack/router/issues/6221)
**Why It Happens**: The `head()` function can execute before the route `loader()` finishes, causing meta tags to use placeholder or undefined data.

**Prevention**:
```typescript
// ✗ 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](https://github.com/TanStack/router/issues/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**:
```typescript
// 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](https://tkdodo.eu/blog/the-beauty-of-tan-stack-router)
**Why It Happens**: Type definitions differ between runtime hook and type helper. `NavigateOptions` is less strict.

**Prevention**:
```typescript
// ✗ 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](https://tanstack.com/router/latest/docs/framework/react/how-to/debug-router-issues)
**Why It Happens**: Very common beginner mistake - using `'about'` instead of `'/about'` causes route matching failures.

**Prevention**:
```typescript
// ✗ 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):
```typescript
import { cloudflare } from '@cloudflare/vite-plugin'

export default defineConfig({
  plugins: [TanStackRouterVite(), react(), cloudflare()],
})
```

**API Routes Pattern** (fetch from Workers backend):
```typescript
// 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.

Overview

This skill provides type-safe, file-based React routing using TanStack Router, with built-in support for route loaders, search param validation, and TanStack Query integration. It includes tooling and patterns to prevent 20 documented issues—ranging from devtools dependency problems and Vite plugin order bugs to param parsing and SSR streaming crashes. It’s optimized for TypeScript SPAs and developer workflows with Vite, virtual routes, and Zod validation adapters. The skill focuses on reliable navigation, typed loaders, and safe server-client error handling.

How this skill works

The skill inspects your route file layout and generates a typed route tree used by createRouter, enabling autocompletion and compile-time safety for links, params, and loaders. It wires up route-level loaders, beforeLoad hooks for auth, and TanStack Query prefetching so loaders return typed data and the UI consumes cached queries. It also documents known issues and concrete workarounds—plugin ordering for Vite, zod-based search validation patterns, virtual file routes, and handling of aborted loaders and server function validation serialization.

When to use it

  • Implementing file-based, type-safe routing in a React + TypeScript SPA.
  • Adding route-level data loaders with TanStack Query prefetch and caching.
  • Validating URL search params with runtime schemas (Zod adapter recommended).
  • Protecting routes with beforeLoad (auth redirects) without flash of content.
  • Troubleshooting Vite plugin order, devtools dependency, or SSR streaming crashes.

Best practices

  • Install @tanstack/router-plugin and ensure TanStackRouterVite appears before react() in Vite plugins.
  • Import the generated routeTree in your main entry to register route types for Link and useParams autocomplete.
  • Use zodValidator (from @tanstack/zod-adapter) with fallback/.catch() rules to robustly parse search params.
  • Await or catch promises inside loaders to avoid SSR streaming crashes; handle undefined errors in errorComponent.
  • When using useParams({ strict: false }), manually parse values or prefer the default strict mode for typed params.

Example use cases

  • Build a blog SPA with file routes like /posts/$postId, typed loaders, and prefetching via TanStack Query.
  • Create an authenticated section using a pathless layout route with beforeLoad to redirect unauthenticated users.
  • Mix file-based and programmatic routing with virtual file routes for complex nested layouts.
  • Validate search filters on a product listing with Zod and use typed Route.useSearch() in the component.
  • Work around dev tooling issues: fix devtools dependency errors and adjust Vite config plugin order.

FAQ

What if routeTree.gen.ts is missing?

Ensure the TanStackRouterVite plugin is listed before react() in vite.config and that the plugin runs during build to generate routeTree.gen.

How do I handle stringified Zod errors from server functions?

Catch the error on the client and parse error.message as JSON when it starts with '[' to recover structured issues until the upstream fix lands.

Why are params strings after navigation with strict: false?

This is a known regression; prefer default strict mode or manually coerce values (e.g., Number or z.coerce) when using strict: false.