AgentSkillsCN

tanstack-router

使用TanStack Router构建类型安全、基于文件的React路由。支持客户端导航、路由加载器,以及与TanStack Query的集成。可有效预防20种已知的错误,包括验证结构丢失、参数解析错误,以及SSR流式渲染崩溃。 在实施基于文件的路由模式、使用TypeScript进行路由的SPA开发,或在排查devtools依赖错误、类型安全问题、Vite打包问题,或Docker部署问题时使用此功能。

SKILL.md
--- frontmatter
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/react-router@1.146.2

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:

code
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 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 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, 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:

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 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, 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:

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 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 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, PR #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 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 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 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 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.