
Tanstack Router
- 93 installs
- 14 repo stars
- Updated March 2, 2026
- oakoss/agent-skills
Helps with ai & agent building tasks during AI-assisted development.
About
tanstack-router is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- tanstack-router
- AI & Agent Building
- AI-coding skill
Tanstack Router by the numbers
- 93 all-time installs (skills.sh)
- +2 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #4,706 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/oakoss/agent-skills --skill tanstack-routerAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 93 |
|---|---|
| repo stars | ★ 14 |
| Last updated | March 2, 2026 |
| Repository | oakoss/agent-skills ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
TanStack Router
Type-safe, file-based routing for React with route-level data loading, search params validation, code splitting, and TanStack Query integration.
Package: @tanstack/react-router | Plugin: @tanstack/router-plugin
Quick Reference
| Pattern | Usage |
|---|---|
createFileRoute('/path') | Define file-based route |
createRootRouteWithContext<T>() | Root route with typed context |
createLazyFileRoute('/path') | Code-split route component |
zodValidator(schema) | Search params validation |
Route.useLoaderData() | Access loader data in component |
Route.useParams() | Type-safe route params |
Route.useSearch() | Type-safe search params |
useNavigate() | Programmatic navigation |
useBlocker() | Block navigation (dirty forms) |
notFound() | Throw 404 from loader |
getRouteApi('/path') | Type-safe hooks in split files |
stripSearchParams(defaults) | Clean default values from URLs |
retainSearchParams(['key']) | Preserve params across navs |
useAwaited({ promise }) | Suspend until deferred promise resolves |
useCanGoBack() | Check if router can go back safely |
Data Loading
| Method | Returns | Throws | Use Case |
|---|---|---|---|
ensureQueryData | Data | Yes | Route loaders (recommended) |
prefetchQuery | void | No | Background prefetching |
fetchQuery | Data | Yes | Immediate data need |
defer() (optional) | Promise | No | Stream non-critical data (promises auto-handled) |
Preloading
| Strategy | Behavior | Use Case |
|---|---|---|
'intent' | Preload on hover/focus | Default for most links |
'render' | Preload when Link mounts | Critical next pages |
'viewport' | Preload when Link in view | Below-fold content |
false | No preloading | Heavy, rarely-visited pages |
File Organization
| File | Purpose |
|---|---|
__root.tsx | Root route with <Outlet /> |
index.tsx | Index route for / |
posts.$postId.tsx | Dynamic param route |
_authenticated.tsx | Pathless layout (auth guard) |
dashboard.lazy.tsx | Code-split component |
Common Mistakes
| Mistake | Fix |
|---|---|
| Missing router type registration | Add declare module with Register interface |
useParams() without from | Always pass from: '/route/path' for exact types |
useNavigate() for regular links | Use <Link> for <a> tags, a11y, preloading |
prefetchQuery in loaders | Use ensureQueryData (returns data, throws errors) |
Fetching in useEffect | Use route loaders (prevents waterfalls) |
| Sequential fetches in loader | Use Promise.all for parallel requests |
| Missing leading slash | Always '/about' not 'about' |
| TanStackRouterVite after react() | Plugin MUST come before react() in Vite config |
strict: false params unparsed | Use strict mode or manually parse after navigation |
| Pathless route notFoundComponent | Define notFoundComponent on child routes instead |
| Aborted loader undefined error | Guard errorComponent with if (!error) return null |
No loaderDeps declared | Declare deps so loader only re-runs when they change |
Delegation
- TanStack Query patterns — data fetching, caching, mutations: use
tanstack-queryskill - TanStack Start — server functions, SSR, server-side auth: use
tanstack-startskill - TanStack Table — table rendering with router search params: use
tanstack-tableskill - Router + Query integration — loader data flow, preloading: see Loader Data Flow Patterns
If the tanstack-devtools skill is available, delegate router state debugging and route tree inspection to it.References
- Setup — installation, Vite config, file structure, app setup, router default options
- Type Safety — register router,
fromparam,strict: false, type utilities, getRouteApi - Data Loading — route loaders, Query integration, parallel loading, streaming, deferred data, abort signal, loaderDeps
- Search Params — validation, strip/retain middleware, fine-grained subscriptions, debounce, custom serializers
- Navigation — Link, active styling, relative navigation, hash, route masks, blocker, scroll restoration
- Auth and Context — beforeLoad, context inheritance, pathless layouts, dependency injection, error handling
- Code Splitting — lazy routes, auto splitting, preloading strategies, programmatic preloading
- Virtual File Routes — rootRoute, route, index, layout, physical builders, mixing file-based and code-based routing
- Known Issues — 20 documented issues with fixes, anti-patterns
- Loader+Query Patterns — ensureQueryData in loaders, parallel loading, critical vs non-critical data, search-param-dependent loaders
Auth and Context
Authentication with beforeLoad
export const Route = createFileRoute('/(authenticated)')({
beforeLoad: async ({ context, location }) => {
if (!context.auth.isAuthenticated) {
throw redirect({ to: '/login', search: { redirect: location.href } });
}
return {
user: context.auth.user,
permissions: context.auth.user.permissions,
};
},
});
// Child routes receive the extended context automatically
export const Route = createFileRoute('/(authenticated)/admin')({
beforeLoad: async ({ context }) => {
if (!context.permissions.includes('admin')) {
throw redirect({ to: '/unauthorized' });
}
},
});Route Context and Dependency Injection
Define context at root, extend in beforeLoad, consume in loaders and components:
type RouterContext = {
queryClient: QueryClient;
auth: { getSession: () => Promise<Session | null> };
};
export const Route = createRootRouteWithContext<RouterContext>()({
component: RootComponent,
});
const router = createRouter({
routeTree,
context: {
queryClient,
auth: { getSession: () => auth.api.getSession({ headers: getHeaders() }) },
},
});Context Inheritance
Three things flow automatically through nested routes:
| Inheritance | Source | Example |
|---|---|---|
| Path params | Parent routes | postId: string from parent flows to child |
| Search params | Global + route-specific | debug: boolean at root merges with route |
| Route context | beforeLoad return values | { user, permissions } flows to all children |
Types compose from the entire parent hierarchy automatically.
| Context | Loader Data |
|---|---|
| Available in beforeLoad, loader, render | Only available in component |
| Set at router creation or beforeLoad | Returned from loader |
| Flows down to all children | Specific to route |
| Good for services, clients, auth | Good for route-specific data |
Pathless Layout Routes
Group routes by concern with shared layout and context:
routes/
├── _app.tsx # Layout + auth guard for authenticated routes
├── _app/
│ ├── dashboard.tsx # /dashboard
│ ├── settings.tsx # /settings
│ └── profile.tsx # /profile
├── _public.tsx # Layout for public routes
└── _public/
├── login.tsx # /login
└── register.tsx # /registerError and Not-Found Handling
import { notFound } from '@tanstack/react-router';
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) => {
const post = await fetchPost(params.postId);
if (!post) throw notFound();
return { post };
},
notFoundComponent: () => <div>Post not found</div>,
errorComponent: PostErrorComponent,
});Not-found errors bubble up the route tree. Use notFound({ data }) to pass context to the 404 component.
Error Component Implementation
The errorComponent receives error and reset. Guard against undefined error (see Known Issues #12 for aborted loader edge case):
import {
ErrorComponent,
type ErrorComponentProps,
} from '@tanstack/react-router';
import { useQueryErrorResetBoundary } from '@tanstack/react-query';
function PostErrorComponent({ error, reset }: ErrorComponentProps) {
const { reset: resetQuery } = useQueryErrorResetBoundary();
if (!error) return null;
return (
<div role="alert">
<ErrorComponent error={error} />
<button
onClick={() => {
resetQuery();
reset();
}}
>
Retry
</button>
</div>
);
}When using TanStack Query, call useQueryErrorResetBoundary().reset() before the router reset() to clear Query's error state and allow refetching.
notFound with Data
Pass context to the not-found component:
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) => {
const post = await fetchPost(params.postId);
if (!post) throw notFound({ data: { postId: params.postId } });
return { post };
},
// data is typed unknown — cast to match the shape passed to notFound()
notFoundComponent: ({ data }) => {
const { postId } = data as { postId: string };
return <div>Post {postId} not found</div>;
},
});Code Splitting
Lazy Routes
Split components from critical config into two files:
// routes/dashboard.tsx — critical config only
export const Route = createFileRoute('/dashboard')({
validateSearch: z.object({ tab: z.string().optional() }),
beforeLoad: ({ context }) => {
if (!context.auth.isAuthenticated) throw redirect({ to: '/login' });
},
loader: async ({ context }) =>
context.queryClient.ensureQueryData(dashboardQueries.stats()),
});
// routes/dashboard.lazy.tsx — lazy-loaded component
import { createLazyFileRoute } from '@tanstack/react-router';
export const Route = createLazyFileRoute('/dashboard')({
component: DashboardPage,
pendingComponent: DashboardSkeleton,
errorComponent: DashboardError,
});What Goes Where
Main file (.tsx) | Lazy file (.lazy.tsx) |
|---|---|
validateSearch | component |
beforeLoad | pendingComponent |
loader | errorComponent |
loaderDeps | notFoundComponent |
| context setup |
If a route only has a .lazy.tsx file (no loader/beforeLoad/validateSearch), skip the main file entirely. The router auto-generates a virtual route.
Type Safety in Lazy Files
Use getRouteApi for type-safe hooks in lazy files since the Route export from the main file is not available:
// routes/dashboard.lazy.tsx
import { createLazyFileRoute, getRouteApi } from '@tanstack/react-router';
const dashboardRoute = getRouteApi('/dashboard');
export const Route = createLazyFileRoute('/dashboard')({
component: DashboardPage,
});
function DashboardPage() {
const data = dashboardRoute.useLoaderData();
const { tab } = dashboardRoute.useSearch();
return <Dashboard data={data} activeTab={tab} />;
}Auto Code Splitting
Alternative to manual .lazy.tsx files — the plugin splits routes automatically:
TanStackRouterVite({ autoCodeSplitting: true });Auto splitting moves component, pendingComponent, errorComponent, and notFoundComponent to separate chunks. Critical config (loader, beforeLoad, validateSearch) stays in the main bundle.
When using virtual file routes, always use autoCodeSplitting instead of manual lazy files. Manual createLazyFileRoute is silently replaced in virtual route mode (see Known Issues #18).
Preloading Strategies
| Strategy | Behavior | Use Case |
|---|---|---|
'intent' | Preload on hover/focus | Default for most links |
'render' | Preload when Link mounts | Critical next pages |
'viewport' | Preload when Link in view | Below-fold content |
false | No preloading | Heavy, rarely-visited pages |
Configure globally and override per-link:
const router = createRouter({
routeTree,
defaultPreload: 'intent',
defaultPreloadDelay: 50,
defaultPreloadStaleTime: 30_000,
});
// Override for specific links
<Link to="/heavy-page" preload={false}>Heavy Page</Link>
<Link to="/critical-page" preload="render">Critical Page</Link>Set defaultPreloadStaleTime: 0 when using TanStack Query to let Query manage cache freshness.
Programmatic Preloading
Preload routes in response to events:
const router = useRouter();
async function handleMouseEnter(postId: string) {
await router.preloadRoute({
to: '/posts/$postId',
params: { postId },
});
}Preloading loads both the route code (lazy chunks) and executes loaders. The preloadDelay setting prevents excessive requests on quick mouse movements.
Data Loading
Route Loaders
Loaders execute before render, preventing loading waterfalls:
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) => ({
post: await fetchPost(params.postId),
}),
component: () => {
const { post } = Route.useLoaderData();
return <h1>{post.title}</h1>;
},
});TanStack Query Integration
Use ensureQueryData, not prefetchQuery:
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 } = useSuspenseQuery(postOpts(postId));
return <h1>{data.title}</h1>;
},
});Parallel Loading
Nested routes load in parallel by default. Within a single loader, use Promise.all:
export const Route = createFileRoute('/dashboard')({
beforeLoad: async () => {
const [user, config] = await Promise.all([fetchUser(), fetchAppConfig()]);
return { user, config };
},
loader: async ({ context: { queryClient } }) => {
await Promise.all([
queryClient.ensureQueryData(statsQueries.overview()),
queryClient.ensureQueryData(activityQueries.recent()),
]);
},
});Parent and child loaders run simultaneously:
// routes/posts.tsx — runs in parallel with child
export const Route = createFileRoute('/posts')({
loader: async ({ context: { queryClient } }) => {
await queryClient.ensureQueryData(categoryQueries.all());
},
});
// routes/posts/$postId.tsx — runs in parallel with parent
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params, context: { queryClient } }) => {
await Promise.all([
queryClient.ensureQueryData(postQueries.detail(params.postId)),
queryClient.ensureQueryData(commentQueries.forPost(params.postId)),
]);
},
});Loading timeline comparison:
Without parallelization:
|- beforeLoad (parent) ========
|- loader (parent) ========
|- beforeLoad (child) ====
|- loader (child) ========
|- Render =
With parallelization:
|- beforeLoad (parent) ========
|- beforeLoad (child) ====
|- loader (parent) ========
|- loader (child) ============
|- Render =Key rules: beforeLoad runs before loader (for auth, context setup). Parent context is available in child loaders only after beforeLoad completes.
Streaming Non-Critical Data
Await critical data, prefetch the rest:
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params, context: { queryClient } }) => {
const post = await queryClient.ensureQueryData(
postQueries.detail(params.postId),
);
queryClient.prefetchQuery(commentQueries.forPost(params.postId));
queryClient.prefetchQuery(relatedQueries.forPost(params.postId));
return { post };
},
component: PostPage,
});
function PostPage() {
const { post } = Route.useLoaderData();
const { postId } = Route.useParams();
const { data: comments, isLoading } = useQuery(
commentQueries.forPost(postId),
);
return (
<article>
<PostContent post={post} />
{isLoading ? <CommentsSkeleton /> : <Comments data={comments} />}
</article>
);
}Deferred Data
Stream non-critical data after initial render by returning unawaited promises from loaders. Promises are handled automatically — defer() is no longer required. Consume deferred promises with the <Await> component or the useAwaited hook:
export const Route = createFileRoute('/dashboard')({
loader: async () => {
const user = await fetchUser();
return {
user,
stats: fetchStats(),
activity: fetchActivity(),
};
},
component: () => {
const { user, stats, activity } = Route.useLoaderData();
return (
<div>
<h1>Welcome, {user.name}</h1>
<Suspense fallback={<StatsSkeleton />}>
<Await promise={stats}>
{(data) => <StatsDisplay data={data} />}
</Await>
</Suspense>
<Suspense fallback={<ActivitySkeleton />}>
<Await promise={activity}>
{(data) => <ActivityFeed data={data} />}
</Await>
</Suspense>
</div>
);
},
});useAwaited Hook
Hook-based alternative to <Await> — suspends until the deferred promise resolves:
import { useAwaited } from '@tanstack/react-router';
function StatsPanel() {
const { stats } = Route.useLoaderData();
const data = useAwaited({ promise: stats });
return <StatsDisplay data={data} />;
}Loader Cause and Preload
Loaders receive cause and preload to distinguish navigation types:
cause | Description |
|---|---|
'preload' | Triggered by link hover/focus |
'enter' | Initial navigation to route |
'stay' | Route re-entered (search/dep change) |
Use preload to conditionally load less data during prefetching:
loader: async ({ preload, context: { queryClient } }) => {
if (preload) {
await queryClient.prefetchQuery(postListOptions);
return;
}
const [posts, stats] = await Promise.all([
queryClient.ensureQueryData(postListOptions),
queryClient.ensureQueryData(statsOptions),
]);
return { posts, stats };
};Abort Signal
Cancel in-flight requests on navigation:
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params, abortController }) => {
const response = await fetch(`/api/posts/${params.postId}`, {
signal: abortController.signal,
});
if (!response.ok) {
if (response.status === 404) throw notFound();
throw new Error('Failed to fetch post');
}
return response.json();
},
});Loader Dependencies
Declare which search params the loader depends on so it only re-runs when those change:
export const Route = createFileRoute('/posts')({
validateSearch: z.object({ page: z.number().default(1) }),
loaderDeps: ({ search }) => ({ page: search.page }),
loader: async ({ deps }) => fetchPosts({ page: deps.page }),
});Preventing Unnecessary Re-Fetches
Without loaderDeps, the loader re-runs on every search param change. With loaderDeps, it only re-runs when the returned value changes (compared via structural sharing):
export const Route = createFileRoute('/products')({
validateSearch: z.object({
page: z.number().default(1),
sort: z.enum(['name', 'price']).default('name'),
highlight: z.string().optional(),
}),
loaderDeps: ({ search }) => ({
page: search.page,
sort: search.sort,
}),
loader: async ({ deps }) => fetchProducts(deps),
});Changing highlight (cosmetic param) does not re-run the loader. Only page and sort trigger re-fetches. Return only the values the loader actually uses.
When using TanStack Query, loaderDeps and queryKey serve complementary roles: loaderDeps controls when the loader re-runs, and queryKey controls Query's cache identity. Keep them aligned:
const productOpts = (deps: { page: number; sort: string }) =>
queryOptions({
queryKey: ['products', deps],
queryFn: () => fetchProducts(deps),
});
export const Route = createFileRoute('/products')({
loaderDeps: ({ search }) => ({ page: search.page, sort: search.sort }),
loader: ({ deps, context: { queryClient } }) =>
queryClient.ensureQueryData(productOpts(deps)),
});Automatic Suspense
Every route wraps in <Suspense> and <ErrorBoundary> automatically. Route components only need happy-path rendering:
function PostPage() {
const { data } = useSuspenseQuery(postOptions(postId));
// ^? Post (guaranteed, never undefined)
return <h1>{data.title}</h1>;
}Known Issues
Build and Setup
#1: Devtools dependency resolution — Build fails with @tanstack/router-devtools-core not found. Fix: npm install @tanstack/router-devtools.
#2: Vite plugin order (CRITICAL) — Routes not auto-generated, routeTree.gen.ts missing. Fix: TanStackRouterVite MUST come before react() in plugins array. The plugin processes route files before React compilation.
#3: Type registration missing — <Link to="..."> not typed, no autocomplete. Fix: Add declare module '@tanstack/react-router' with Register interface in main.tsx.
#4: Loader not running — Loader function not called on navigation. Fix: Ensure route exports Route constant from createFileRoute.
Routing and Navigation
#6: Virtual routes index/layout conflict — route.tsx and index.tsx conflict when using physical(). Fix: Use pathless route _layout.tsx + _layout.index.tsx.
#11: Pathless route notFoundComponent not rendering — notFoundComponent on pathless layout routes (e.g., /(authenticated)) ignored. Fix: Define notFoundComponent on child routes instead.
#18: Virtual routes don't support manual lazy loading — createLazyFileRoute silently replaced with createFileRoute. Use autoCodeSplitting: true instead.
#20: Missing leading slash — Routes fail to match when path defined without leading slash. Always use '/about' not 'about'.
Search Params and Validation
#7: Search params type inference — zodSearchValidator broken since v1.81.5. Fix: Use zodValidator from @tanstack/zod-adapter.
#10: useParams({ strict: false }) returns unparsed values — After navigation, params are strings instead of parsed types. Fix: Use strict mode (default) or manually parse:
export const Route = createFileRoute('/posts/$postId')({
params: {
parse: (params) => ({
postId: z.coerce.number().parse(params.postId),
}),
},
});
function Component() {
const { postId } = Route.useParams(); // Parsed as number
// const { postId } = useParams({ strict: false }) // String — avoid
}#19: NavigateOptions type inconsistency — NavigateOptions type doesn't enforce required params like useNavigate() does. Use useNavigate() return type for safety.
Loaders and Data Loading
#12: Aborted loader renders errorComponent with undefined error — Rapid navigation aborts previous loader, renders errorComponent with undefined. Fix:
errorComponent: ({ error, reset }) => {
if (!error) return null;
return <div>Error: {error.message}</div>;
},#17: Route head() executes before loader finishes — Meta tags generated with incomplete data. Workaround: guard against undefined loaderData in head() function.
SSR and Deployment
#14: Streaming SSR loader crash — Unawaited promise rejections crash dev server. Fix: Always await or try/catch in loaders. Never use void with promise chains that may throw.
#15: Prerender hangs on empty filter — Build hangs when prerender.filter returns zero routes. Ensure at least one route matches or disable prerender.
#16: Docker prerender failure — Preview server not accessible in Docker. Fix: Add preview: { host: true } to vite config to bind to 0.0.0.0.
Anti-Patterns
- Fetching in useEffect instead of route loaders — creates waterfalls, no preloading
- Using `prefetchQuery` in loaders instead of
ensureQueryData— swallows errors, no return value - Missing router type registration — no autocomplete, no type checking on routes
- Using `useParams()` without `from` — returns union of all route params instead of exact types
- Using `useNavigate()` for links — loses right-click, accessibility, SEO, preloading
- Sequential fetches in loaders — use
Promise.allfor parallel requests - Importing globals instead of using route context — harder to test, couples to implementation
- Creating empty main route files — use virtual routes when only a
.lazy.tsxis needed - Using `createLazyFileRoute` in virtual file routes — silently replaced, use
autoCodeSplittinginstead
Loader Data Flow Patterns
Define Reusable Query Options
// lib/queries/posts.ts
import { queryOptions } from '@tanstack/react-query';
export const postQueries = {
all: () =>
queryOptions({
queryKey: ['posts'],
queryFn: fetchPosts,
staleTime: 5 * 60 * 1000,
}),
detail: (id: string) =>
queryOptions({
queryKey: ['posts', id],
queryFn: () => fetchPost(id),
staleTime: 5 * 60 * 1000,
}),
comments: (postId: string) =>
queryOptions({
queryKey: ['posts', postId, 'comments'],
queryFn: () => fetchComments(postId),
}),
};Basic Loader + Component Pattern
Prefetch in loaders, consume with useSuspenseQuery:
// routes/posts.tsx
export const Route = createFileRoute('/posts')({
loader: async ({ context: { queryClient } }) => {
await queryClient.ensureQueryData(postQueries.all());
},
component: PostsPage,
});
function PostsPage() {
const { data: posts } = useSuspenseQuery(postQueries.all());
return <PostList posts={posts} />;
}Parallel Data Loading
export const Route = createFileRoute('/dashboard')({
loader: async ({ context: { queryClient } }) => {
await Promise.all([
queryClient.ensureQueryData(statsQueries.overview()),
queryClient.ensureQueryData(activityQueries.recent()),
queryClient.ensureQueryData(userQueries.current()),
]);
},
component: DashboardPage,
});
function DashboardPage() {
const { data: stats } = useSuspenseQuery(statsQueries.overview());
const { data: activity } = useSuspenseQuery(activityQueries.recent());
const { data: user } = useSuspenseQuery(userQueries.current());
return <Dashboard stats={stats} activity={activity} user={user} />;
}Critical vs Non-Critical Data
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params, context: { queryClient } }) => {
// Critical - await it
await queryClient.ensureQueryData(postQueries.detail(params.postId));
// Non-critical - prefetch but don't block
queryClient.prefetchQuery(postQueries.comments(params.postId));
},
component: PostPage,
});
function PostPage() {
const { postId } = Route.useParams();
const { data: post } = useSuspenseQuery(postQueries.detail(postId));
const { data: comments, isLoading } = useQuery(postQueries.comments(postId));
return (
<article>
<PostContent post={post} />
{isLoading ? <CommentsSkeleton /> : <Comments data={comments} />}
</article>
);
}Search-Param-Dependent Loaders
Use loaderDeps to re-run loaders when search params change:
import { createFileRoute } from '@tanstack/react-router';
import { zodValidator } from '@tanstack/zod-adapter';
import { useSuspenseQuery } from '@tanstack/react-query';
import { z } from 'zod';
const searchSchema = z.object({
page: z.number().default(1),
size: z.number().default(10),
sort: z.enum(['name', 'email', 'createdAt']).default('createdAt'),
filter: z.string().optional(),
});
export const Route = createFileRoute('/admin/users')({
validateSearch: zodValidator(searchSchema),
loaderDeps: ({ search }) => search,
loader: async ({ context: { queryClient }, deps }) => {
await queryClient.ensureQueryData(userQueries.list(deps));
},
component: UsersPage,
});
function UsersPage() {
const search = Route.useSearch();
const { data } = useSuspenseQuery(userQueries.list(search));
return <UserTable data={data} />;
}loaderDeps declares which values the loader depends on. When those values change (search params update), the loader re-runs. Without loaderDeps, the loader only runs on initial navigation.
Data Flow Summary
Navigation Starts
|
Router matches route
|
loader() executes
|
ensureQueryData() checks cache
|
Fresh cache? -> Return cached Stale/missing? -> Fetch and cache
| |
Route renders Route renders
| |
useSuspenseQuery returns data useSuspenseQuery returns dataKey Points
ensureQueryDatarespectsstaleTime-- won't refetch fresh datauseSuspenseQuerythrows promise to Suspense if data missing- Loaders enable preloading on link hover via
defaultPreload: 'intent' - Use
useQuery(notuseSuspenseQuery) for non-critical data that can load after render - Query invalidation and background updates still work normally
Navigation
Link Component
Prefer <Link> over useNavigate() for proper <a> tags, right-click, accessibility, SEO, and preloading:
<Link
to="/posts/$postId"
params={{ postId: '123' }}
search={{ tab: 'comments' }}
activeProps={{ className: 'nav-link-active', 'aria-current': 'page' }}
activeOptions={{ exact: true }}
preload="intent"
preloadDelay={100}
disabled={!post.published}
replace={false}
>
View Post
</Link>Reserve useNavigate() for side effects: form submissions, auth redirects, programmatic navigation.
Link Component Props
| Prop | Type | Description |
|---|---|---|
to | string | Target route path |
params | object | Route params (type-safe) |
search | `object \ | (prev) => object` |
hash | string | URL hash fragment |
state | object | History state (survives navigation) |
replace | boolean | Replace history entry instead of push |
resetScroll | boolean | Reset scroll position on navigation |
disabled | boolean | Disable link (prevents navigation) |
preload | `false \ | 'intent' \ |
preloadDelay | number | Delay (ms) before intent preloading |
activeProps | object | Props applied when link is active |
inactiveProps | object | Props applied when link is inactive |
activeOptions | ActiveLinkOptions | Control active matching behavior |
mask | MaskOptions | Display different URL (route masking) |
Disabled Links
Disable navigation conditionally:
<Link
to="/edit-post"
params={{ postId: post.id }}
disabled={!hasPermission}
className="data-[status=disabled]:opacity-50 data-[status=disabled]:cursor-not-allowed"
>
Edit Post
</Link>Disabled links render with aria-disabled="true" and prevent navigation, but still render as <a> tags for consistency.
Replace vs Push
Control history behavior with replace:
// Default: Push new entry to history
<Link to="/dashboard">Dashboard</Link>
// Replace current entry (no new history entry)
<Link to="/login" replace>
Login
</Link>
// Useful after form submission
function CreatePost() {
const { mutate } = useMutation({
mutationFn: createPost,
onSuccess: (post) => {
navigate({
to: '/posts/$postId',
params: { postId: post.id },
replace: true, // Replace /create with /posts/123
});
},
});
}Use replace for redirects, auth flows, and post-submission navigations where users shouldn't go back to the form.
Active Link Styling
Three approaches, from simplest to most flexible:
// 1. activeProps / inactiveProps
<Link
to="/dashboard"
activeProps={{ className: 'text-primary font-semibold' }}
inactiveProps={{ className: 'text-muted-foreground' }}
>
Dashboard
</Link>
// 2. data-status attribute (CSS-driven, no re-render on state change)
<Link to="/posts" className="data-[status=active]:text-primary">
Posts
</Link>
// 3. useMatchRoute for complex logic
const matchRoute = useMatchRoute();
const isOnPosts = matchRoute({ to: '/posts', fuzzy: true });activeOptions controls matching behavior:
| Option | Default | Effect |
|---|---|---|
exact | false | Match only exact path (not children) |
includeSearch | false | Include search params in active check |
includeHash | false | Include hash in active check |
explicitUndefined | false | Treat undefined search params as explicit |
Preloading Strategies
Control when route data loads with the preload prop:
// Preload on hover or focus (recommended default)
<Link to="/posts/$postId" params={{ postId: post.id }} preload="intent">
{post.title}
</Link>
// Preload immediately when Link mounts
<Link to="/dashboard" preload="render">
Dashboard
</Link>
// Preload when Link enters viewport
<Link to="/settings" preload="viewport">
Settings
</Link>
// Disable preloading (for heavy routes)
<Link to="/admin/reports" preload={false}>
Reports
</Link>Add delay to avoid excessive preloading on fast mouse movements:
<Link
to="/posts/$postId"
params={{ postId: post.id }}
preload="intent"
preloadDelay={100} // Wait 100ms after hover
>
{post.title}
</Link>Preload strategies inherit from route config and router defaults if not explicitly set.
Relative Navigation
Use useNavigate({ from }) for type-safe relative paths:
const navigate = useNavigate({ from: '/posts/$postId' });
navigate({ to: '..', search: { page: 1 } }); // Go to /posts
navigate({ to: '.', search: (prev) => ({ ...prev }) }); // Stay, update searchHash Navigation
<Link to="." hash="comments">Jump to Comments</Link>
<Link to="/about" hash="team">Meet the Team</Link>
navigate({ hash: 'section-2' });Route Masks (Modal URLs)
Route masks display one URL while internally routing to another. Use for modals, side panels, and quick views:
function PostList() {
return (
<div>
{posts.map((post) => (
<Link
key={post.id}
to="/posts/$postId"
params={{ postId: post.id }}
mask={{
to: '/posts',
search: { preview: post.id },
}}
>
{post.title}
</Link>
))}
<Outlet />
</div>
);
}Programmatic navigation with mask:
navigate({
to: '/posts/$postId',
params: { postId: post.id },
mask: { to: '/posts' },
});| Scenario | URL Shown | Actual Route |
|---|---|---|
| Click masked link | Masked URL | Real route |
| Share/copy URL | Real URL | Real route |
| Direct navigation | Real URL | Real route |
| Browser refresh | URL in bar | Matches URL |
Block Navigation (Dirty Forms)
Basic blocking with confirmation dialog:
import { useBlocker } from '@tanstack/react-router';
function EditForm() {
const [isDirty, setIsDirty] = useState(false);
useBlocker({
shouldBlockFn: () => {
if (!isDirty) return false;
return !window.confirm('You have unsaved changes. Leave anyway?');
},
});
}Advanced blocking with custom UI using withResolver:
function EditForm() {
const [isDirty, setIsDirty] = useState(false);
const { proceed, reset, status, next } = useBlocker({
shouldBlockFn: ({ current, next }) => {
if (!isDirty) return false;
return true;
},
enableBeforeUnload: true,
withResolver: true,
});
return (
<div>
<form>{/* form fields */}</form>
{status === 'blocked' ? (
<dialog open>
<p>Leave for {next?.pathname}? You have unsaved changes.</p>
<button onClick={reset}>Stay</button>
<button onClick={proceed}>Leave</button>
</dialog>
) : null}
</div>
);
}Check Back Navigation
useCanGoBack returns whether the router can go back without exiting the application (experimental):
import { useRouter, useCanGoBack } from '@tanstack/react-router';
function BackButton() {
const router = useRouter();
const canGoBack = useCanGoBack();
return canGoBack ? (
<button onClick={() => router.history.back()}>Go back</button>
) : null;
}Returns false when history is at index 0 or after a reloadDocument navigation resets the history index.
Catch-All Splat Route
routes/$.tsx catches all unmatched paths. The _splat param contains the matched path:
// routes/$.tsx
export const Route = createFileRoute('/$')({
component: CatchAllComponent,
});
function CatchAllComponent() {
const { _splat } = Route.useParams();
return <div>Page not found: /{_splat}</div>;
}History State
Store ephemeral state that survives navigation but not page refresh:
navigate({
to: '/posts/$postId',
params: { postId: '123' },
state: { fromFeed: true, scrollPosition: 500 },
});
function PostPage() {
const state = useRouterState({ select: (s) => s.location.state });
const fromFeed = state?.fromFeed;
}Scroll Restoration
Enable globally in router config:
const router = createRouter({
routeTree,
scrollRestoration: true,
});Custom element scroll restoration with useElementScrollRestoration:
import { useElementScrollRestoration } from '@tanstack/react-router';
function PostsComponent() {
const scrollEntry = useElementScrollRestoration({ id: 'posts-container' });
return (
<div
id="posts-container"
ref={(el) => {
if (el) el.scrollTop = scrollEntry?.scrollY ?? 0;
}}
data-scroll-restoration-id="posts-container"
>
{/* content */}
</div>
);
}Preserve scroll when updating filters:
navigate({
search: (prev) => ({ ...prev, filter }),
resetScroll: false,
});
// Reset scroll explicitly
<Link to="/posts" resetScroll>Posts</Link>Search Params
Validation with Zod
Always validate search params — they are user-controlled input:
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),
});Use .catch() to silently fix malformed params. Use .default() + errorComponent to show validation errors.
Manual Validation
Plain function approach without external validators:
type ProductSearch = {
page: number;
sort: 'asc' | 'desc';
category?: string;
};
export const Route = createFileRoute('/products')({
validateSearch: (search: Record<string, unknown>): ProductSearch => ({
page: Number(search.page) || 1,
sort: search.sort === 'desc' ? 'desc' : 'asc',
category: typeof search.category === 'string' ? search.category : undefined,
}),
});Validation with Valibot
Valibot is a lighter alternative to Zod with the same adapter pattern:
import { valibotValidator, fallback } from '@tanstack/valibot-adapter';
import * as v from 'valibot';
const searchSchema = v.object({
page: fallback(v.pipe(v.number(), v.integer(), v.minValue(1)), 1),
sort: v.optional(v.picklist(['name', 'date'])),
});
export const Route = createFileRoute('/search')({
validateSearch: valibotValidator(searchSchema),
});Install: npm install @tanstack/valibot-adapter valibot
Updating Search Params
const navigate = useNavigate();
navigate({
to: '.',
search: (prev) => ({ ...prev, sort: 'price', page: 1 }),
});stripSearchParams Middleware
Remove default values from URLs for cleaner links:
import { stripSearchParams } from '@tanstack/react-router';
const defaults = { page: 1, sort: 'newest' as const };
export const Route = createFileRoute('/posts')({
validateSearch: zodValidator(searchSchema),
search: { middlewares: [stripSearchParams(defaults)] },
});| Input | Behavior |
|---|---|
stripSearchParams(defaultsObj) | Strip params matching defaults |
stripSearchParams(['key1', 'key2']) | Strip specific keys |
stripSearchParams(true) | Strip all params (only if no required params) |
retainSearchParams Middleware
Preserve specific search params across navigations:
import { retainSearchParams } from '@tanstack/react-router';
export const Route = createRootRoute({
validateSearch: zodValidator(globalSchema),
search: { middlewares: [retainSearchParams(['debug', 'theme'])] },
});Custom Search Middleware
Transform search params before URL serialization:
export const Route = createFileRoute('/posts')({
validateSearch: searchSchema,
search: {
middlewares: [
({ search, next }) => {
const cleaned = Object.fromEntries(
Object.entries(search).filter(([_, v]) => v !== undefined),
);
return next(cleaned);
},
],
},
});Fine-Grained Subscriptions
Use select to subscribe to specific search values and prevent unnecessary re-renders:
function PostsPage() {
const page = Route.useSearch({ select: (s) => s.page });
const isFiltered = Route.useSearch({ select: (s) => Boolean(s.filter) });
}Search params use structural sharing — when only filter changes, components subscribed only to page won't re-render.
Debounced URL Sync
function FiltersComponent() {
const search = Route.useSearch();
const navigate = useNavigate();
const [localFilter, setLocalFilter] = useState(search.filter ?? '');
useEffect(() => {
const timeout = setTimeout(() => {
navigate({
search: (prev) => ({ ...prev, filter: localFilter || undefined }),
replace: true,
});
}, 300);
return () => clearTimeout(timeout);
}, [localFilter]);
return (
<input
value={localFilter}
onChange={(e) => setLocalFilter(e.target.value)}
/>
);
}Custom Serializers
Configure once on router for cleaner URLs:
import JSURL from 'jsurl2';
const router = createRouter({
routeTree,
search: {
serialize: (search) => JSURL.stringify(search),
parse: (searchString) => JSURL.parse(searchString) || {},
},
});Setup
Installation
npm install @tanstack/react-router @tanstack/router-devtools
npm install -D @tanstack/router-plugin
npm install @tanstack/zod-adapter zod # Optional: Zod validationVite Config
TanStackRouterVite MUST come before react():
import { TanStackRouterVite } from '@tanstack/router-plugin/vite';
export default defineConfig({
plugins: [TanStackRouterVite(), react()],
});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';
const router = createRouter({ routeTree });
declare module '@tanstack/react-router' {
interface Register {
router: typeof router;
}
}
<RouterProvider router={router} />Router Default Options
export function getRouter() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { refetchOnWindowFocus: false, staleTime: 1000 * 60 * 2 },
},
});
const router = createRouter({
routeTree,
context: { queryClient, user: null },
defaultPreload: 'intent',
defaultPreloadStaleTime: 0,
defaultErrorComponent: DefaultCatchBoundary,
defaultNotFoundComponent: DefaultNotFound,
scrollRestoration: true,
// WARNING: only works with JSON-serializable data (no Date, Map, Set, class instances)
defaultStructuralSharing: true,
defaultPendingComponent: () => <div className="loading-bar" />,
defaultPendingMinMs: 200,
defaultPendingMs: 1000,
});
return router;
}| Option | Type | Default | Description |
|---|---|---|---|
defaultPreload | `false \ | 'intent' \ | 'render' \ |
defaultPreloadStaleTime | number | 30000 | Preloaded data freshness (ms) |
defaultErrorComponent | Component | Built-in | Global error boundary |
defaultNotFoundComponent | Component | Built-in | Global 404 page |
scrollRestoration | boolean | false | Restore scroll on navigation |
defaultStructuralSharing | boolean | false | Optimize loader data re-renders |
defaultPendingComponent | Component | None | Shown during route transitions |
defaultPendingMs | number | 1000 | Delay before showing pending UI |
defaultPendingMinMs | number | 500 | Minimum time pending UI shows |
DefaultCatchBoundary Component
Global error boundary for unhandled route errors:
import {
ErrorComponent,
Link,
rootRouteId,
useMatch,
useRouter,
} from '@tanstack/react-router';
function DefaultCatchBoundary({ error }: { error: unknown }) {
const router = useRouter();
const isRoot = useMatch({
strict: false,
select: (state) => state.id === rootRouteId,
});
return (
<div>
<ErrorComponent error={error} />
<div>
<button onClick={() => router.invalidate()}>Try Again</button>
{isRoot ? (
<a href="/">Home</a>
) : (
<Link
to="/"
onClick={(e) => {
e.preventDefault();
router.navigate({ to: '/' });
}}
>
Home
</Link>
)}
</div>
</div>
);
}DefaultNotFound Component
Global 404 component for unmatched routes:
function DefaultNotFound() {
return (
<div>
<p>Page not found</p>
<Link to="/">Go home</Link>
</div>
);
}DefaultPendingComponent
Shown after defaultPendingMs delay to avoid flash for fast transitions:
function DefaultPendingComponent() {
return <div className="route-loading-indicator" />;
}Timing: if navigation completes within defaultPendingMs, no pending UI flashes. Once shown, it stays for at least defaultPendingMinMs to prevent layout thrash.
SSR with TanStack Query
Recommended: @tanstack/react-router-ssr-query
Automates dehydration/hydration and streaming for SSR with TanStack Query:
import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query';
const queryClient = new QueryClient();
const router = createRouter({ routeTree, context: { queryClient } });
setupRouterSsrQueryIntegration({ router, queryClient });Install: npm install @tanstack/react-router-ssr-query
The legacy @tanstack/react-router-with-query (routerWithQueryClient) requires @tanstack/react-start and is superseded by this package.
Manual dehydrate/hydrate/Wrap
For custom SSR integration without the helper package, use dehydrate, hydrate, and Wrap router options:
export function createAppRouter() {
const queryClient = new QueryClient();
return createRouter({
routeTree,
context: { queryClient },
dehydrate: () => ({
queryClientState: dehydrate(queryClient),
}),
hydrate: (dehydrated) => {
hydrate(queryClient, dehydrated.queryClientState);
},
Wrap: ({ children }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
),
});
}Type Safety
Register Router Type (CRITICAL)
Without registration, useNavigate, useParams, useSearch, and <Link> have no type inference:
import { createRouter } from '@tanstack/react-router';
import { routeTree } from './routeTree.gen';
const router = createRouter({ routeTree });
declare module '@tanstack/react-router' {
interface Register {
router: typeof router;
}
}After registration, <Link to="/invalid" /> produces a TypeScript error and to autocompletes all valid routes.
Use from for Type Narrowing (CRITICAL)
const params = useParams({ from: '/posts/$postId' });
// params: { postId: string }
const search = useSearch({ from: '/search' });
// search: { query: string; page: number }Without from, hooks return a union of all possible params/search across all routes.
Route-Specific Helpers
Within route files, use Route.useX() methods for automatic type narrowing:
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) => fetchPost(params.postId),
component: PostPage,
});
function PostPage() {
const { postId } = Route.useParams();
const data = Route.useLoaderData();
const search = Route.useSearch();
}Route.fullPath for Type Narrowing
Route.fullPath provides the full path string for use with hooks in non-route files:
const params = useParams({ from: Route.fullPath });This is equivalent to passing the string literal but keeps it co-located with the route definition.
getRouteApi for Code-Split Components
In code-split (.lazy.tsx) files that don't have access to the Route export:
import { getRouteApi } from '@tanstack/react-router';
const postRoute = getRouteApi('/posts/$postId');
function PostPage() {
const params = postRoute.useParams();
const data = postRoute.useLoaderData();
const search = postRoute.useSearch();
}getRouteApi is a zero-cost abstraction that provides the same type-safe hooks as Route.useX().
strict: false for Shared Components
When a component is used across multiple routes and doesn't know which route it's in:
function Breadcrumbs() {
const params = useParams({ strict: false });
// ^? { postId?: string, userId?: string, ... }
const search = useSearch({ strict: false });
// ^? Partial<FullSearchSchema>
return <nav>{/* Build breadcrumbs from available params */}</nav>;
}Only use strict: false in truly generic cross-route components. Note: parsed param types may not be correct after navigation (see Known Issues #10).
Type Route Context
interface RouterContext {
queryClient: QueryClient;
auth: { user: User | null; isAuthenticated: boolean };
}
export const Route = createRootRouteWithContext<RouterContext>()({
component: RootComponent,
});Context types compose from the entire parent hierarchy. Child routes automatically inherit typed context from parent beforeLoad return values.
NavigateOptions Type Safety
The NavigateOptions type is less strict than useNavigate() about enforcing required params. Prefer the hook's return type:
const navigate = useNavigate();
navigate({
to: '/posts/$postId',
params: { postId: '123' },
});
// NavigateOptions doesn't enforce params — avoid using it directly as a typeType Utilities
Extract route types programmatically:
import type {
RouteIds,
RegisteredRouter,
ParseRoute,
} from '@tanstack/react-router';
type AllRouteIds = RouteIds<RegisteredRouter['routeTree']>;
type PostParams = ParseRoute<
RegisteredRouter['routeTree'],
'/posts/$postId'
>['params'];Virtual File Routes (v1.140+)
Programmatic route configuration when file-based conventions don't fit. Virtual file routes let you define the route tree in code while keeping route implementations in separate files.
When to Use
- Route structure doesn't map cleanly to filesystem hierarchy
- Need routes from multiple directories
- Want explicit control over route nesting
- Need to mix file-based subtrees with code-defined routes
Setup
Configure in tsr.config.json or the Vite plugin:
TanStackRouterVite({
virtualRouteConfig: './src/routes.ts',
});Or inline in the Vite plugin:
import {
rootRoute,
route,
index,
layout,
physical,
} from '@tanstack/virtual-file-routes';
TanStackRouterVite({
virtualRouteConfig: rootRoute('root.tsx', [
index('home.tsx'),
route('/about', 'about.tsx'),
]),
});Builders
rootRoute(file, children)
Defines the root route. All other routes nest inside:
import { rootRoute } from '@tanstack/virtual-file-routes';
export const routes = rootRoute('root.tsx', [
// children here
]);The file path (root.tsx) is relative to the routes directory.
index(file)
Defines an index route (renders at the parent's exact path):
rootRoute('root.tsx', [
index('home.tsx'), // renders at '/'
]);route(path, file, children?)
Defines a route with an explicit URL path:
rootRoute('root.tsx', [
route('/posts', 'posts/layout.tsx', [
index('posts/list.tsx'),
route('$postId', 'posts/detail.tsx'),
route('$postId/edit', 'posts/edit.tsx'),
]),
route('/about', 'about.tsx'),
]);Dynamic segments use $ prefix, same as file-based routing.
layout(id, file, children)
Defines a pathless layout route (wraps children without adding a URL segment):
rootRoute('root.tsx', [
layout('authenticated', 'layouts/auth-layout.tsx', [
route('/dashboard', 'dashboard.tsx'),
route('/settings', 'settings.tsx'),
]),
layout('public', 'layouts/public-layout.tsx', [
route('/login', 'login.tsx'),
route('/register', 'register.tsx'),
]),
]);The id parameter uniquely identifies the layout (used in generated route IDs).
physical(path, directory)
Delegates a subtree to file-based routing conventions:
rootRoute('root.tsx', [
index('home.tsx'),
route('/about', 'about.tsx'),
physical('/docs', 'docs'),
]);The docs directory uses standard file-based routing conventions. This lets you mix approaches within a single app.
Full Example
import {
rootRoute,
route,
index,
layout,
physical,
} from '@tanstack/virtual-file-routes';
export const routes = rootRoute('root.tsx', [
index('home.tsx'),
layout('marketing', 'layouts/marketing.tsx', [
route('/features', 'marketing/features.tsx'),
route('/pricing', 'marketing/pricing.tsx'),
]),
layout('app', 'layouts/app.tsx', [
route('/dashboard', 'app/dashboard.tsx', [
index('app/dashboard-home.tsx'),
route('analytics', 'app/analytics.tsx'),
route('settings', 'app/settings.tsx'),
]),
route('/projects', 'app/projects.tsx', [
index('app/projects-list.tsx'),
route('$projectId', 'app/project-detail.tsx'),
]),
]),
physical('/blog', 'blog'),
]);Corresponding file structure:
src/routes/
├── root.tsx
├── home.tsx
├── layouts/
│ ├── marketing.tsx
│ └── app.tsx
├── marketing/
│ ├── features.tsx
│ └── pricing.tsx
├── app/
│ ├── dashboard.tsx
│ ├── dashboard-home.tsx
│ ├── analytics.tsx
│ ├── settings.tsx
│ ├── projects.tsx
│ ├── projects-list.tsx
│ └── project-detail.tsx
└── blog/
├── index.tsx # file-based: /blog
├── $slug.tsx # file-based: /blog/$slug
└── categories.tsx # file-based: /blog/categoriesRoute File Implementation
Route files for virtual routes use createFileRoute with the generated path:
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/dashboard')({
component: DashboardPage,
loader: async ({ context }) =>
context.queryClient.ensureQueryData(dashboardQueries.stats()),
});
function DashboardPage() {
const data = Route.useLoaderData();
return <div>{data.stats.totalUsers} users</div>;
}The route path string in createFileRoute is auto-generated by the plugin.
Code Splitting with Virtual Routes
Use autoCodeSplitting instead of manual .lazy.tsx files:
TanStackRouterVite({
autoCodeSplitting: true,
virtualRouteConfig: './src/routes.ts',
});Manual createLazyFileRoute is silently replaced in virtual route mode. Always use autoCodeSplitting instead (see Known Issues #18).
Index and Layout Conflict
When using physical(), avoid placing both route.tsx and index.tsx in the same directory. Use a pathless layout instead:
# Problem: route.tsx and index.tsx conflict
docs/
├── route.tsx # layout for /docs
└── index.tsx # index for /docs — CONFLICT
# Solution: pathless layout
docs/
├── _layout.tsx # pathless layout wrapper
├── _layout.index.tsx # index for /docs
└── $slug.tsx # /docs/$slugSee Known Issues #6 for details.
Config File Approach
For larger apps, export the config from a separate file:
import { rootRoute, route, index, layout } from '@tanstack/virtual-file-routes';
export const routes = rootRoute('root.tsx', [
index('home.tsx'),
route('/about', 'about.tsx'),
]);Reference it in tsr.config.json:
{
"virtualRouteConfig": "./src/routes.ts"
}