TanStack Router patterns for type-safe, file-based routing. Covers installation, route configuration, typed params/search, layouts, and navigation...
Type-safe, file-based routing for React applications with TanStack Router.
pnpm add @tanstack/react-router
pnpm add -D @tanstack/router-plugin
// vite.config.ts
import { TanStackRouterVite } from '@tanstack/router-plugin/vite'
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [
react(),
TanStackRouterVite(), // Generates route tree
],
})
TanStack Router v1.x+ (2025) introduces automatic code splitting that separates critical route configuration from non-critical components.
// vite.config.ts
import { TanStackRouterVite } from '@tanstack/router-plugin/vite'
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [
react(),
TanStackRouterVite({
autoCodeSplitting: true, // NEW: Enable automatic splitting
}),
],
})
What Gets Split:
| Critical (Always Loaded) | Non-Critical (Lazy Loaded) |
|---|---|
| Route configuration | Component |
| Loaders | Error component |
| Search params validation | Pending component |
| beforeLoad | Not-found component |
Benefits:
.lazy.tsx files needed)When to Use:
// src/main.tsx
import { StrictMode } from 'react'
import ReactDOM from 'react-dom/client'
import { RouterProvider, createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
const router = createRouter({ routeTree })
// Register router for type safety
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>
)
src/routes/
āāā __root.tsx # Root layout (Outlet, providers)
āāā index.tsx # "/" route
āāā about.tsx # "/about" route
āāā users/
ā āāā index.tsx # "/users" route
ā āāā $userId.tsx # "/users/:userId" route (dynamic)
āāā posts/
āāā $postId/
ā āāā index.tsx # "/posts/:postId" route
ā āāā edit.tsx # "/posts/:postId/edit" route
āāā index.tsx # "/posts" route
Naming Conventions:
__root.tsx - Root layout (contains <Outlet />)index.tsx - Index route for that path$param.tsx - Dynamic parameter (e.g., $userId ā :userId)_layout.tsx - Layout route (no URL segment)route.lazy.tsx - Lazy-loaded routeVirtual file routes allow the router to auto-generate route anchors without physical files:
src/routes/
āāā __root.tsx # Root layout
āāā index.tsx # "/" (physical file)
āāā about.lazy.tsx # "/about" (virtual route, lazy only)
āāā users/
āāā index.tsx # "/users" (physical)
āāā $userId.lazy.tsx # "/users/:userId" (virtual, lazy only)
Key Insight: If you only need a component (no loader, no search validation), you can delete the base route file. The router auto-generates a virtual anchor for .lazy.tsx files.
Example - Minimal About Page:
// src/routes/about.lazy.tsx
// No about.tsx needed! Router creates virtual anchor
import { createLazyFileRoute } from '@tanstack/react-router'
export const Route = createLazyFileRoute('/about')({
component: () => <div>About Us</div>,
})
When Virtual Routes Make Sense:
// src/routes/__root.tsx
import { createRootRoute, Outlet } from '@tanstack/react-router'
import { TanStackRouterDevtools } from '@tanstack/router-devtools'
export const Route = createRootRoute({
component: () => (
<>
<nav>
<Link to="/">Home</Link>
<Link to="/about">About</Link>
<Link to="/users">Users</Link>
</nav>
<main>
<Outlet /> {/* Child routes render here */}
</main>
<TanStackRouterDevtools /> {/* Auto-hides in production */}
</>
),
})
// src/routes/about.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/about')({
component: AboutComponent,
})
function AboutComponent() {
return <div>About Page</div>
}
// src/routes/users/$userId.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/users/$userId')({
component: UserComponent,
})
function UserComponent() {
const { userId } = Route.useParams() // Fully typed!
return <div>User ID: {userId}</div>
}
// src/routes/users/index.tsx
import { createFileRoute } from '@tanstack/react-router'
import { z } from 'zod'
const userSearchSchema = z.object({
page: z.number().default(1),
filter: z.enum(['active', 'inactive', 'all']).default('all'),
search: z.string().optional(),
})
export const Route = createFileRoute('/users/')({
validateSearch: userSearchSchema,
component: UsersComponent,
})
function UsersComponent() {
const { page, filter, search } = Route.useSearch() // Fully typed!
return (
<div>
<p>Page: {page}</p>
<p>Filter: {filter}</p>
{search && <p>Search: {search}</p>}
</div>
)
}
import { Link } from '@tanstack/react-router'
// Basic navigation
<Link to="/about">About</Link>
// With params
<Link to="/users/$userId" params={{ userId: '123' }}>
View User
</Link>
// With search params
<Link
to="/users"
search={{ page: 2, filter: 'active' }}
>
Users Page 2
</Link>
// With state
<Link to="/details" state={{ from: 'home' }}>
Details
</Link>
// Active link styling
<Link
to="/about"
activeProps={{ className: 'text-blue-600 font-bold' }}
inactiveProps={{ className: 'text-gray-600' }}
>
About
</Link>
import { useNavigate } from '@tanstack/react-router'
function MyComponent() {
const navigate = useNavigate()
const handleClick = () => {
// Navigate to route
navigate({ to: '/users' })
// With params
navigate({ to: '/users/$userId', params: { userId: '123' } })
// With search
navigate({ to: '/users', search: { page: 2 } })
// Replace history
navigate({ to: '/login', replace: true })
// Go back
navigate({ to: '..' }) // Relative navigation
}
return <button onClick={handleClick}>Navigate</button>
}
Basic Loader:
// src/routes/users/$userId.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/users/$userId')({
loader: async ({ params }) => {
const user = await fetchUser(params.userId)
return { user }
},
component: UserComponent,
})
function UserComponent() {
const { user } = Route.useLoaderData() // Fully typed!
return <div>{user.name}</div>
}
With TanStack Query Integration (see tanstack-query skill for details):
import { queryClient } from '@/app/queryClient'
import { userQueryOptions } from '@/features/users/queries'
export const Route = createFileRoute('/users/$userId')({
loader: ({ params }) =>
queryClient.ensureQueryData(userQueryOptions(params.userId)),
component: UserComponent,
})
.lazy.tsx Pattern)For fine-grained control over code splitting, split routes into critical and lazy files:
Critical Route File (posts.tsx):
// src/routes/posts.tsx - Loaded immediately
import { createFileRoute } from '@tanstack/react-router'
import { postsQueryOptions } from '@/features/posts/queries'
import { queryClient } from '@/app/queryClient'
export const Route = createFileRoute('/posts')({
// Critical: loaders, search validation, beforeLoad
loader: () => queryClient.ensureQueryData(postsQueryOptions()),
validateSearch: (search) => postsSearchSchema.parse(search),
})
Lazy Route File (posts.lazy.tsx):
// src/routes/posts.lazy.tsx - Code-split, loaded on navigation
import { createLazyFileRoute } from '@tanstack/react-router'
import { Posts } from '@/features/posts/components/Posts'
export const Route = createLazyFileRoute('/posts')({
// Non-critical: component, error/pending/notFound
component: Posts,
pendingComponent: () => <PostsSkeleton />,
errorComponent: ({ error }) => <PostsError error={error} />,
})
Lazy-Only Properties:
component - The main route componenterrorComponent - Error boundary UIpendingComponent - Loading/suspense UInotFoundComponent - 404 UI for this routeCritical-Only Properties (NOT in lazy):
loader / loaderDepsbeforeLoadvalidateSearchsearch (search middleware)contextBest Practice: Use automatic code splitting (autoCodeSplitting: true) unless you need specific control over what goes in each file.
getRouteApi)When building components outside the route file, use getRouteApi for type-safe access:
// src/features/posts/components/PostHeader.tsx
import { getRouteApi } from '@tanstack/react-router'
// Get typed route API without importing the route
const routeApi = getRouteApi('/posts/$postId')
export function PostHeader() {
// All these are fully typed!
const { postId } = routeApi.useParams()
const { post } = routeApi.useLoaderData()
const { view } = routeApi.useSearch()
const context = routeApi.useRouteContext()
return (
<header>
<h1>{post.title}</h1>
<span>Viewing: {view}</span>
</header>
)
}
Available Methods:
useParams() - Route parametersuseSearch() - Search/query paramsuseLoaderData() - Data from loaderuseRouteContext() - Route contextuseMatch() - Full route match objectWhen to Use:
Important: The route path string must exactly match the route definition.
Layout Route (_layout.tsx - no URL segment):
// src/routes/_layout.tsx
import { createFileRoute, Outlet } from '@tanstack/react-router'
export const Route = createFileRoute('/_layout')({
component: LayoutComponent,
})
function LayoutComponent() {
return (
<div className="dashboard-layout">
<Sidebar />
<div className="content">
<Outlet /> {/* Child routes */}
</div>
</div>
)
}
// Child routes
// src/routes/_layout/dashboard.tsx ā "/dashboard"
// src/routes/_layout/settings.tsx ā "/settings"
Route groups organize files without affecting URLs using parentheses:
src/routes/
āāā (auth)/ # Group (not in URL)
ā āāā login.tsx # "/login"
ā āāā register.tsx # "/register"
ā āāā forgot-password.tsx # "/forgot-password"
āāā (dashboard)/ # Group (not in URL)
ā āāā _layout.tsx # Shared dashboard layout
ā āāā index.tsx # "/" (or "/dashboard")
ā āāā analytics.tsx # "/analytics"
ā āāā settings.tsx # "/settings"
āāā __root.tsx
Benefits:
Example - Auth Group Layout:
// src/routes/(auth)/_layout.tsx
export const Route = createFileRoute('/(auth)/_layout')({
component: () => (
<div className="auth-layout">
<Logo />
<Outlet />
</div>
),
})
export const Route = createFileRoute('/users')({
loader: async () => {
const users = await fetchUsers()
return { users }
},
pendingComponent: () => <Spinner />,
errorComponent: ({ error }) => <ErrorMessage>{error.message}</ErrorMessage>,
component: UsersComponent,
})
import { ErrorComponent } from '@tanstack/react-router'
export const Route = createFileRoute('/users')({
loader: async () => {
const users = await fetchUsers()
if (!users) throw new Error('Failed to load users')
return { users }
},
errorComponent: ({ error, reset }) => (
<div>
<h1>Error loading users</h1>
<p>{error.message}</p>
<button onClick={reset}>Try Again</button>
</div>
),
component: UsersComponent,
})
Providing Context:
// src/routes/__root.tsx
export const Route = createRootRoute({
beforeLoad: () => ({
user: getCurrentUser(),
}),
component: RootComponent,
})
// Access in child routes
export const Route = createFileRoute('/dashboard')({
component: function Dashboard() {
const { user } = Route.useRouteContext()
return <div>Welcome, {user.name}</div>
},
})
// src/routes/_authenticated.tsx
import { createFileRoute, redirect } from '@tanstack/react-router'
export const Route = createFileRoute('/_authenticated')({
beforeLoad: ({ context }) => {
if (!context.user) {
throw redirect({ to: '/login' })
}
},
component: Outlet,
})
// Protected routes
// src/routes/_authenticated/dashboard.tsx
// src/routes/_authenticated/profile.tsx
Hover Preload:
<Link
to="/users/$userId"
params={{ userId: '123' }}
preload="intent" // Preload on hover
>
View User
</Link>
Options:
preload="intent" - Preload on hover/focuspreload="render" - Preload when link renderspreload={false} - No preload (default)import { TanStackRouterDevtools } from '@tanstack/router-devtools'
// Add to root layout
<TanStackRouterDevtools position="bottom-right" />
Auto-hides in production builds.
route.lazy.tsx for code splittingCatch-All (Splat) Routes:
v1.x Syntax:
// src/routes/files/$.tsx - Catches all paths under /files/
export const Route = createFileRoute('/files/$')({
component: FileViewer,
})
function FileViewer() {
// Access splat via '_splat' key (v1.x+)
const { _splat } = Route.useParams()
// '/files/docs/readme.md' ā _splat = 'docs/readme.md'
return <div>File: {_splat}</div>
}
v2 Migration Note:
In TanStack Router v2 (upcoming), splat routes use _splat key consistently:
params['*'] or params._splat (both work)params._splat (star deprecated)Prepare for v2:
// ā
Future-proof
const { _splat } = Route.useParams()
// ā ļø Works in v1, deprecated in v2
const splat = Route.useParams()['*']
404 Not Found Route:
// src/routes/$.tsx
export const Route = createFileRoute('/$')({
component: () => <div>404 Not Found</div>,
})
Optional Params:
// Use search params for optional data
const searchSchema = z.object({
optional: z.string().optional(),
})
Multi-Level Dynamic Routes:
/posts/$postId/comments/$commentId
Insights from large-scale TanStack Router deployments:
Colocate everything a page needs within its route folder:
src/routes/users/
āāā $userId/
ā āāā index.tsx # Route definition
ā āāā index.lazy.tsx # Lazy component
ā āāā UserProfile.tsx # Page-specific component
ā āāā useUserActions.ts # Page-specific hooks
āāā index.tsx
Components/functions belong at the nearest shared ancestor in the hierarchy.
// ā
Recommended - Router handles loading/error
export const Route = createFileRoute('/users/$userId')({
loader: fetchUser,
pendingComponent: UserSkeleton,
errorComponent: UserError,
component: UserProfile, // Only handles happy path!
})
// ā Avoid - Manual loading in component
function UserProfile() {
const { data, isLoading, error } = useUser()
if (isLoading) return <Spinner /> // Router should handle this
if (error) return <Error /> // Router should handle this
return <div>{data.name}</div>
}
// List views ā preload detail on hover
<Link
to="/users/$userId"
params={{ userId }}
preload="intent" // Preload on hover
>
{user.name}
</Link>
// Critical navigation ā preload on render
<Link to="/dashboard" preload="render">
Dashboard
</Link>
If users should be able to share or bookmark a specific view, use search params:
const searchSchema = z.object({
tab: z.enum(['overview', 'activity', 'settings']).default('overview'),
page: z.number().default(1),
sort: z.enum(['name', 'date', 'score']).optional(),
})
TanStack Start is the full-stack meta-framework built on TanStack Router:
Stack:
When to Consider Start:
When to Stick with Router + Vite:
Resources:
Note: Start is still maturing. For production SPAs in 2026, TanStack Router + Query + Vite remains the recommended stack.