Claude Code with TanStack Router: Type-Safe React Routing
Why TanStack Router without CLAUDE.md drops type safety the moment you skip routeTree
TanStack Router's central promise is end-to-end type safety: route params, search params, loader return types, and navigation calls are all inferred from a single generated file called routeTree.gen.ts. The Vite plugin watches your src/routes/ directory and regenerates that file every time you add, rename, or delete a route file. Every typed API in the library, useParams, useSearch, useLoaderData, Link, and useNavigate, reads from routeTree.gen.ts at the TypeScript level. When the file is accurate, the compiler catches every routing mistake before the code runs.
Claude Code does not know this architecture by default. Without explicit constraints, it will hand-edit routeTree.gen.ts to add a route it thinks is missing, write useParams() without a from argument and silently widen the return type to Record<string, string>, add search params to a route object without a validateSearch function so query strings arrive as untyped strings, and install react-router-dom because that is the router it sees most often in its training data.
Each of these is a type-safety violation that does not error at runtime immediately. The app keeps running. The params are accessible. The tests pass. The compiler starts accepting params.postId as string | undefined instead of string, search.filter as unknown instead of string | undefined, and <Link to="/posts/$postId"> without a params prop instead of requiring it. Weeks later a developer writes a navigation call that skips a required param, and the compiler says nothing because the types degraded silently.
This guide covers the CLAUDE.md configuration that locks Claude Code to TanStack Router 1.x's actual model. If you are setting up Claude Code in a new project, the Claude Code setup guide covers the initial configuration. For the TanStack Query integration that pairs with the loader patterns in this guide, Claude Code with TanStack Query covers the conventions that compose with file-based routing.
The TanStack Router CLAUDE.md template
The CLAUDE.md at your project root is read at the start of every Claude Code session. For a TanStack Router project it needs to declare: the router version and Vite plugin setup, the file-based routing conventions, the hard rule against editing routeTree.gen.ts, the createRootRoute and createFileRoute patterns, search param validation policy, loader conventions, and the specific typed hooks that replace their untyped counterparts.
# TanStack Router rules
## Stack
- TanStack Router 1.x, TypeScript 5.x strict
- React 18.x, Vite 5.x
- @tanstack/router-plugin/vite for routeTree generation
- Zod for search param validation
- @tanstack/react-query for server state (optional, via context)
## Vite plugin (vite.config.ts)
import { TanStackRouterVite } from '@tanstack/router-plugin/vite';
export default defineConfig({
plugins: [TanStackRouterVite(), react()],
});
## File-based routing (src/routes/)
- Routes live in src/routes/
- src/routes/__root.tsx , root route (createRootRoute)
- src/routes/index.tsx , / route
- src/routes/posts/index.tsx , /posts route
- src/routes/posts/$postId.tsx , /posts/$postId route (dynamic segment)
- src/routes/posts/$postId/edit.tsx , /posts/$postId/edit (nested)
- Layout routes: src/routes/_layout.tsx (underscore prefix = pathless layout)
- 404 route: src/routes/$.tsx (catch-all splat)
## NEVER edit routeTree.gen.ts
- routeTree.gen.ts is auto-generated by the Vite plugin on every file save
- NEVER modify routeTree.gen.ts by hand
- NEVER import from routeTree.gen.ts directly in application code
- Adding a new route = create the route file, the Vite plugin handles the rest
- If routeTree.gen.ts is out of date, run: npx tsr generate
## Route definition pattern (createFileRoute)
Every route file exports a Route constant. Nothing else defines the route.
// src/routes/posts/$postId.tsx
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/posts/$postId')({
loader: ({ params }) => fetchPost(params.postId),
component: PostPage,
});
function PostPage() {
const { postId } = Route.useParams();
const post = Route.useLoaderData();
return <article>{post.title}</article>;
}
## Root route (src/routes/__root.tsx)
import { createRootRouteWithContext, Outlet } from '@tanstack/react-router';
interface RouterContext {
queryClient: QueryClient; // inject via router context if using TanStack Query
}
export const Route = createRootRouteWithContext<RouterContext>()({
component: () => (
<html>
<body><Outlet /></body>
</html>
),
});
## Typed hooks, always use Route.useParams / Route.useSearch
- Route.useParams() , fully typed, no from needed
- Route.useLoaderData() , typed to loader return
- useParams({ from: '/posts/$postId' }) , typed cross-component alternative
- useSearch({ from: '/posts/$postId' }) , typed cross-component alternative
- useNavigate() , typed via Route.useNavigate() or standalone
- NEVER call useParams() without a from argument outside the route component file
- NEVER call useSearch() without a from argument outside the route component file
## Search params, always use validateSearch
- Every route with query params MUST define validateSearch
- Use Zod for the validator (catches invalid query strings at runtime)
- Default values go in the schema, not in the component
import { z } from 'zod';
export const Route = createFileRoute('/posts')({
validateSearch: z.object({
page: z.number().int().min(1).default(1),
filter: z.string().optional(),
sort: z.enum(['asc', 'desc']).default('desc'),
}),
component: PostList,
});
function PostList() {
const { page, filter, sort } = Route.useSearch();
// page: number, filter: string | undefined, sort: 'asc' | 'desc'
}
## Hard rules
- NEVER hand-edit routeTree.gen.ts
- NEVER use useParams() without from outside the route's own component file
- NEVER add search params to a route without validateSearch
- NEVER use react-router-dom or wouter, this project uses TanStack Router only
- NEVER use a plain HTML <a> tag for internal navigation, use <Link> from @tanstack/react-router
- ALWAYS use createFileRoute('/exact/path'), the path string must match the file path exactly
- ALWAYS export the route as const Route (capital R), the Vite plugin looks for this export
This template prevents the three failure modes Claude introduces most frequently. The never-edit-routeTree rule blocks the silent type degradation that occurs when Claude modifies the generated file to patch a mismatch it cannot diagnose another way. The validateSearch rule ensures search params are never untyped strings. The from requirement on cross-component hook calls keeps TypeScript from widening param types to Record<string, string>.
File-based routing and the generated routeTree
TanStack Router 1.x uses the file system as the route definition. The path of a file inside src/routes/ maps directly to the URL it handles. The Vite plugin scans that directory on startup and on every file change, builds an internal representation of the route tree, and writes it to routeTree.gen.ts. The router instance in main.tsx imports that generated file and nothing else.
The naming conventions Claude needs to follow:
src/routes/
__root.tsx → root layout (always)
index.tsx → /
about.tsx → /about
posts/
index.tsx → /posts
$postId.tsx → /posts/:postId ($ prefix = dynamic segment)
$postId/
index.tsx → /posts/:postId
edit.tsx → /posts/:postId/edit
_layout.tsx → pathless layout (no URL segment)
$.tsx → 404 / catch-all splat
Add this to CLAUDE.md alongside the above template to make the conventions explicit:
## Route file naming (src/routes/)
- Dynamic segment: $paramName.tsx (dollar prefix)
- Nested route: parent/$param/child.tsx
- Pathless layout: _name.tsx or _name/ (underscore prefix, adds no URL segment)
- Index route: index.tsx inside a directory
- Catch-all: $.tsx
- Dots in filenames become path separators: posts.drafts.tsx → /posts/drafts (flat alternative to nested dirs)
- Parentheses for groups that do not add URL segments: (auth)/login.tsx → /login
The flat-file alternative with dots is something Claude will default to nested directories for. Either works, but pick one pattern per project and lock it in CLAUDE.md so Claude does not mix styles across routes.
The routeTree.gen.ts file looks intimidating when you open it. It contains the full route manifest, type declarations for every route's params, search params, loader data, and context, and the createRouter call. Leave it closed. The Vite plugin owns it. When a generated type is wrong, the fix is always in the route file, not in routeTree.gen.ts.
createRootRoute and createFileRoute in practice
Every route in TanStack Router is created with either createRootRoute (or createRootRouteWithContext) for the root, or createFileRoute for every other file. The route object accepts a configuration that can include a component, a loader, a validateSearch function, a beforeLoad hook, an errorComponent, and a pendingComponent.
Add this section to CLAUDE.md to cover the full route object shape:
## Route object options
// Minimal route
export const Route = createFileRoute('/about')({
component: AboutPage,
});
// Route with loader
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) => {
const post = await fetchPost(params.postId);
if (!post) throw notFound(); // TanStack Router built-in
return post;
},
component: PostPage,
errorComponent: PostError,
pendingComponent: PostSkeleton,
});
// Route with auth guard
export const Route = createFileRoute('/dashboard')({
beforeLoad: ({ context, location }) => {
if (!context.auth.isAuthenticated) {
throw redirect({
to: '/login',
search: { redirect: location.href },
});
}
},
component: Dashboard,
});
// notFound and redirect are imported from @tanstack/react-router
// throw notFound() , renders the nearest notFoundComponent
// throw redirect({}) , redirects before the component mounts
The beforeLoad hook is where authentication guards live in TanStack Router. Claude will reach for a wrapper component or a useEffect redirect by default. The beforeLoad approach runs before the component renders, avoids the flash of protected content, and gives the compiler full context about when a route is reachable.
Search params with Zod validators
Search params in TanStack Router are type-safe only when a route declares a validateSearch function. Without it, useSearch() returns an empty object regardless of what is in the URL. Claude will add search params to the component via new URLSearchParams(window.location.search) if validateSearch is not established as the pattern in CLAUDE.md.
The validateSearch function receives the raw query string parsed as Record<string, unknown> and must return the validated, typed object. Using Zod for this gives you coercion, defaults, and error handling in one declaration:
import { createFileRoute } from '@tanstack/react-router';
import { z } from 'zod';
const searchSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
filter: z.string().optional(),
sort: z.enum(['asc', 'desc']).default('desc'),
category: z.string().optional(),
});
export const Route = createFileRoute('/posts')({
validateSearch: searchSchema,
component: PostList,
});
function PostList() {
const { page, filter, sort, category } = Route.useSearch();
// TypeScript knows: page is number, sort is 'asc' | 'desc', filter is string | undefined
const navigate = Route.useNavigate();
function goToPage(newPage: number) {
navigate({
search: (prev) => ({ ...prev, page: newPage }),
// TypeScript checks that newPage matches the page field type
});
}
}
The search: (prev) => ({ ...prev, page: newPage }) pattern is important. Passing a function receives the current search state and returns the next state. This preserves other search params that should not change when only the page updates. Passing a plain object replaces all search params. Both are valid. Lock one pattern in CLAUDE.md and note when to use each.
Add this to CLAUDE.md:
## Search param navigation patterns
// Merge update (preserves other params)
navigate({ search: (prev) => ({ ...prev, page: 2 }) });
// Full replacement (clears all other params)
navigate({ search: { page: 1 } });
// Link with search params (fully typed)
<Link to="/posts" search={{ page: 1, sort: 'asc' }}>Next page</Link>
// TypeScript errors if page or sort do not match the route's validateSearch schema
## Rules
- ALWAYS use z.coerce.number() for numeric query params, URL query strings are always strings initially
- ALWAYS set defaults in the Zod schema, not in the component
- ALWAYS use the function form of search when merging, not the object form
- NEVER use URLSearchParams directly for routes managed by TanStack Router
The z.coerce.number() entry matters specifically for page numbers and IDs that come from the URL. ?page=2 delivers the string "2", not the number 2. Without coercion, z.number() fails validation and the router falls back to the default. With coercion, it converts the string automatically.
Loaders and preload
Loaders run before the route component renders. They are the idiomatic place to fetch data in TanStack Router. The component can call Route.useLoaderData() and receive the already-resolved value with no loading state to manage.
Add this section to CLAUDE.md:
## Loader patterns
// Basic loader
export const Route = createFileRoute('/posts/$postId')({
loader: ({ params }) => fetchPost(params.postId),
component: PostPage,
});
function PostPage() {
const post = Route.useLoaderData();
// post is typed to the return type of fetchPost, no loading state needed
return <h1>{post.title}</h1>;
}
// Loader with error handling
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) => {
const post = await fetchPost(params.postId);
if (!post) throw notFound();
return post;
},
notFoundComponent: () => <p>Post not found</p>,
pendingComponent: () => <p>Loading...</p>,
errorComponent: ({ error }) => <p>Error: {error.message}</p>,
component: PostPage,
});
// Loader with context (for QueryClient access)
export const Route = createFileRoute('/posts')({
loader: ({ context: { queryClient } }) =>
queryClient.ensureQueryData({ queryKey: ['posts'], queryFn: fetchPosts }),
component: PostList,
});
## Preload
- preload: 'intent' enables hover preloading on <Link> pointing to this route
- Add to the router config, not individual routes
// router.tsx
const router = createRouter({
routeTree,
defaultPreload: 'intent',
defaultPreloadStaleTime: 0,
context: { queryClient },
});
## Rules
- ALWAYS prefer loaders over useEffect data fetching for route-level data
- NEVER call Route.useLoaderData() outside the route's component tree
- pendingComponent and errorComponent are route-level, not page-level wrappers
- throw notFound() in loaders, not a conditional render in the component
The defaultPreload: 'intent' configuration makes every <Link> in the app start the loader for its destination when the user hovers or focuses the link. This is a single line in the router config that eliminates the perceived latency of navigation for most routes. Claude will not add it unless it is in CLAUDE.md because the router works correctly without it.
Type-safe Link, useNavigate, and useSearch
The typed navigation surface is where TanStack Router's type safety is most visible during development. Every <Link>, every navigate() call, and every useSearch() call is checked against the route tree at compile time.
Add this section to CLAUDE.md:
## Typed navigation
// Link, params and search are type-checked against the destination route
<Link to="/posts/$postId" params={{ postId: post.id }}>
{post.title}
</Link>
// TypeScript errors: missing params
<Link to="/posts/$postId">Read more</Link>
// Error: Property 'params' is missing
// Link with search params
<Link
to="/posts"
search={{ page: 2, sort: 'asc' }}
>
Page 2
</Link>
// Programmatic navigation
const navigate = useNavigate();
navigate({ to: '/posts/$postId', params: { postId: '42' } });
// Active link styling
<Link
to="/posts"
activeProps={{ className: 'font-bold text-brand' }}
activeOptions={{ exact: true }}
>
Posts
</Link>
// Cross-component useParams (requires from)
import { useParams } from '@tanstack/react-router';
function PostHeader() {
const { postId } = useParams({ from: '/posts/$postId' });
return <span>{postId}</span>;
}
// Cross-component useSearch (requires from)
function FilterBadge() {
const { filter } = useSearch({ from: '/posts' });
return filter ? <span>Filter: {filter}</span> : null;
}
## Rules
- ALWAYS pass params to Link when the destination has dynamic segments
- NEVER use <a href="/posts/42"> for internal routes
- useNavigate() returns a typed function, do not ignore its type parameters
- When calling useParams or useSearch outside the route component, always pass from
- from must be the exact route path string, not a pattern or partial path
The from parameter is the detail Claude omits most consistently. Inside the route's own component file, Route.useParams() and Route.useSearch() are always available and always typed because they are bound to the Route constant at the top of the file. In a child component that lives in a separate file, there is no Route reference, so the standalone useParams({ from: '/posts/$postId' }) form is required. Without from, useParams() returns Record<string, string> and TypeScript stops checking param names.
TanStack Query integration
TanStack Router and TanStack Query are designed to work together. The standard integration uses router context to pass the QueryClient instance into every loader, so loaders can call queryClient.ensureQueryData() to prefetch and cache query data before the component renders. This combines loader-first navigation with query-level caching and background refetching.
Add this section to CLAUDE.md:
## TanStack Query integration
// main.tsx, pass queryClient via router context
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { createRouter, RouterProvider } from '@tanstack/react-router';
import { routeTree } from './routeTree.gen';
const queryClient = new QueryClient();
const router = createRouter({
routeTree,
defaultPreload: 'intent',
context: { queryClient },
});
declare module '@tanstack/react-router' {
interface Register {
router: typeof router;
}
}
function App() {
return (
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} context={{ queryClient }} />
</QueryClientProvider>
);
}
// src/routes/posts/$postId.tsx, loader uses queryClient
import { queryOptions } from '@tanstack/react-query';
const postQueryOptions = (postId: string) =>
queryOptions({
queryKey: ['posts', postId],
queryFn: () => fetchPost(postId),
});
export const Route = createFileRoute('/posts/$postId')({
loader: ({ params, context: { queryClient } }) =>
queryClient.ensureQueryData(postQueryOptions(params.postId)),
component: PostPage,
});
function PostPage() {
const { postId } = Route.useParams();
// useQuery re-uses the cache populated by the loader, no duplicate fetch
const { data: post } = useQuery(postQueryOptions(postId));
return <h1>{post?.title}</h1>;
}
## Rules
- ALWAYS declare the router type in 'declare module @tanstack/react-router' for full inference
- ALWAYS use ensureQueryData in loaders, not fetchQuery (ensureQueryData skips if cache is fresh)
- queryOptions() helper centralises queryKey and queryFn, preventing key mismatches
- The component's useQuery re-uses the loader's cache hit, no double fetch
- NEVER call fetch() directly in a loader when a queryOptions object exists for the same data
The queryOptions() helper from TanStack Query v5 is the glue. Defining query options once and using them in both the loader and the component means the query key is always consistent, the cache is shared, and there is no duplicate network call on navigation. Claude Code with TanStack Query covers the full TanStack Query setup that this integration builds on.
Common gotchas Claude introduces without CLAUDE.md
Six patterns appear consistently when Claude Code generates TanStack Router code without the CLAUDE.md template in place. All six are type-safe failures: the app runs, but TypeScript loses the ability to catch routing errors.
Hand-editing routeTree.gen.ts. When Claude cannot figure out why a route is not appearing in the router, it will open routeTree.gen.ts and add the route manually. The Vite plugin overwrites the file on the next save, reverting the change and sometimes breaking the file if the manual edit introduced a formatting error. The fix is always to look at the route file, not the generated output. The most common reason a route does not appear is that the export const Route = createFileRoute(...) line is missing or incorrectly named.
Missing Vite plugin. Claude will generate correct route files that never generate a routeTree.gen.ts because it did not add TanStackRouterVite() to vite.config.ts. The app fails to start with a "routeTree.gen.ts not found" error. The CLAUDE.md vite.config snippet prevents this.
useParams without from. In a component file that is not the route itself, useParams() without from returns Record<string, string>. This compiles and runs. params.postId is accessible. But TypeScript no longer knows that postId exists as a required param on this route. A typo becomes a runtime undefined rather than a compile-time error. The fix is useParams({ from: '/posts/$postId' }).
Search params without validateSearch. A route that reads from the URL's query string without validateSearch receives {} from useSearch(). The component author adds const params = new URLSearchParams(window.location.search) to work around this, exits the TanStack Router type system, and loses navigation type checking on every <Link> to that route. The fix is to add validateSearch with a Zod schema.
Wrong route path string in createFileRoute. The string passed to createFileRoute('/posts/$postId') must exactly match the file path relative to src/routes/. createFileRoute('/posts/post-detail') in a file at src/routes/posts/$postId.tsx causes a mismatch. TypeScript will catch this in most cases, but the error message points at a generated type rather than the route file. The CLAUDE.md rule to match the path string to the file path prevents this.
Plain <a> tags for internal links. Claude will generate <a href="/posts/42"> for navigation if it is not explicitly blocked. This bypasses the router entirely, causes a full page reload, and removes the hover preload behaviour. The fix is <Link to="/posts/$postId" params={{ postId: '42' }}>. For the TypeScript conventions that support strict typing across the full stack that TanStack Router builds on, Claude Code with TypeScript covers the project-wide strict mode setup.
Verifying the setup before writing routes
Before Claude Code writes any route files, confirm the Vite plugin is running and generating routeTree.gen.ts. This is a one-time check that saves hours of debugging.
Add this to CLAUDE.md as a setup verification section:
## Setup verification checklist
Run these before writing any route files:
1. Check vite.config.ts includes TanStackRouterVite() in the plugins array
2. Run: npm run dev
3. Confirm src/routeTree.gen.ts exists and contains createRootRoute and createFileRoute imports
4. Confirm the router in main.tsx imports from './routeTree.gen'
5. Check tsconfig.json has strict: true, TanStack Router type safety depends on strict mode
## If routeTree.gen.ts is missing
- The Vite plugin is not installed or not listed in plugins
- Run: npm install @tanstack/router-plugin
- Add TanStackRouterVite() as the first plugin in vite.config.ts
- Restart the dev server
## If a route does not appear in the router
- Check the file is inside src/routes/ (not src/pages/ or src/views/)
- Check the file exports: export const Route = createFileRoute('...')(...)
- Check the path string matches the file path exactly
- Run: npx tsr generate to manually trigger regeneration
- Restart the dev server if the Vite plugin watcher seems stale
This checklist belongs in CLAUDE.md so Claude runs through it when a route is not resolving rather than editing routeTree.gen.ts. The npx tsr generate command is the manual trigger for route tree regeneration that bypasses the Vite plugin watcher. It is useful in CI and in cases where the watcher has lost track of a file rename.
Type-safe routing that stays type-safe as the project scales
The TanStack Router CLAUDE.md in this guide produces a project where route params are typed because every dynamic segment is declared in a file path and read via Route.useParams(), search params are typed because every route with a query string has a validateSearch Zod schema, navigation is type-checked because <Link> and useNavigate() know the full route tree, loaders run before render so components receive data without managing loading state, and the generated routeTree.gen.ts is owned by the Vite plugin and never touched by hand.
The underlying principle is the same as any framework integration with Claude Code. TanStack Router's type safety is structural: it flows from the route tree, through the generated types, into every navigation call and data hook. Claude generates code that works at runtime but exits that structure at the first unclear choice. A CLAUDE.md that makes the structure explicit keeps Claude inside the type-safe surface throughout the project.
For the mechanics of how CLAUDE.md is read at session start and how to version it alongside your route files, see CLAUDE.md explained. Claudify includes a TanStack Router CLAUDE.md template, pre-configured for file-based routing conventions, validateSearch with Zod, loader-first data fetching, TanStack Query integration, and the type-safe Link and hook patterns shown in this guide.
More like this
Ready to upgrade your Claude Code setup?
Get Claudify