| name | tanstack-router |
| description | Type-safe routing for React and Solid applications with first-class search params, data loading, and seamless integration with the React ecosystem. |
Overview
TanStack Router is a fully type-safe router for React (and Solid) applications. It provides file-based routing, first-class search parameter management, built-in data loading, code splitting, and deep TypeScript integration. It serves as the routing foundation for TanStack Start (the full-stack framework).
Package: @tanstack/react-router
CLI: @tanstack/router-cli or @tanstack/router-plugin (Vite/Rspack/Webpack)
Devtools: @tanstack/react-router-devtools
Installation
npm install @tanstack/react-router
npm install -D @tanstack/router-plugin
npm install -D @tanstack/router-cli
Core Concepts
Route Trees
Routes are organized in a tree structure. The root route is the top-level layout, and child routes nest underneath.
import { createRootRoute, createRoute, createRouter } from '@tanstack/react-router'
const rootRoute = createRootRoute({
component: RootLayout,
})
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: HomePage,
})
const aboutRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/about',
component: AboutPage,
})
const routeTree = rootRoute.addChildren([indexRoute, aboutRoute])
const router = createRouter({ routeTree })
File-Based Routing
File-based routing automatically generates the route tree from your file structure. Configure with Vite plugin:
import { defineConfig } from 'vite'
import { TanStackRouterVite } from '@tanstack/router-plugin/vite'
export default defineConfig({
plugins: [
TanStackRouterVite(),
],
})
File Naming Conventions
| File Pattern | Route Type | Example Path |
|---|
__root.tsx | Root layout | N/A (wraps all) |
index.tsx | Index route | / |
about.tsx | Static route | /about |
$postId.tsx | Dynamic param | /posts/$postId |
posts.tsx | Layout route | /posts/* (layout) |
posts/index.tsx | Nested index | /posts |
posts/$postId.tsx | Nested dynamic | /posts/123 |
posts_.$postId.tsx | Pathless layout | /posts/123 (different layout) |
_layout.tsx | Pathless layout | N/A (groups routes) |
_layout/dashboard.tsx | Grouped route | /dashboard |
$.tsx | Splat/catch-all | /* |
posts.$postId.edit.tsx | Dot notation | /posts/123/edit |
Special Prefixes
_ prefix: Pathless routes (layout groups without URL segment)
$ prefix: Dynamic path parameters
(folder) parentheses: Route groups (organizational, no URL impact)
Route Configuration
Each route can define:
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/posts/$postId')({
params: {
parse: (params) => ({ postId: Number(params.postId) }),
stringify: (params) => ({ postId: String(params.postId) }),
},
validateSearch: (search: Record<string, unknown>) => {
return {
page: Number(search.page ?? 1),
filter: (search.filter as string) || '',
}
},
loader: async ({ params, context, abortController }) => {
return fetchPost(params.postId)
},
loaderDeps: ({ search }) => ({ page: search.page }),
staleTime: 5_000,
preloadStaleTime: 30_000,
errorComponent: PostErrorComponent,
pendingComponent: PostLoadingComponent,
notFoundComponent: PostNotFoundComponent,
beforeLoad: async ({ context, location }) => {
if (!context.auth.isAuthenticated) {
throw redirect({
to: '/login',
search: { redirect: location.href },
})
}
},
head: () => ({
meta: [{ title: 'Post Details' }],
}),
component: PostComponent,
})
function PostComponent() {
const { postId } = Route.useParams()
const post = Route.useLoaderData()
const { page, filter } = Route.useSearch()
return <div>{post.title}</div>
}
Data Loading
Route Loaders
export const Route = createFileRoute('/posts')({
loader: async ({ context }) => {
const posts = await context.queryClient.ensureQueryData({
queryKey: ['posts'],
queryFn: fetchPosts,
})
return { posts }
},
component: PostsComponent,
})
function PostsComponent() {
const { posts } = Route.useLoaderData()
}
Loader Dependencies
Control when loaders re-execute:
export const Route = createFileRoute('/posts')({
loaderDeps: ({ search: { page, filter } }) => ({ page, filter }),
loader: async ({ deps: { page, filter } }) => {
return fetchPosts({ page, filter })
},
})
Deferred Data Loading
Stream non-critical data:
import { Await, defer } from '@tanstack/react-router'
export const Route = createFileRoute('/dashboard')({
loader: async () => {
const criticalData = await fetchCriticalData()
const deferredData = defer(fetchSlowData())
return { criticalData, deferredData }
},
component: DashboardComponent,
})
function DashboardComponent() {
const { criticalData, deferredData } = Route.useLoaderData()
return (
<div>
<CriticalSection data={criticalData} />
<Suspense fallback={<Loading />}>
<Await promise={deferredData}>
{(data) => <SlowSection data={data} />}
</Await>
</Suspense>
</div>
)
}
Context-Based Data Loading
Provide shared dependencies via router context:
const router = createRouter({
routeTree,
context: {
queryClient,
auth: undefined!,
},
})
function App() {
const auth = useAuth()
return <RouterProvider router={router} context={{ auth }} />
}
export const Route = createFileRoute('/protected')({
beforeLoad: ({ context }) => {
if (!context.auth.user) throw redirect({ to: '/login' })
},
loader: ({ context }) => {
return context.queryClient.ensureQueryData(userQueryOptions())
},
})
Search Parameters
Validation
import { z } from 'zod'
const postSearchSchema = z.object({
page: z.number().default(1),
filter: z.string().default(''),
sort: z.enum(['date', 'title']).default('date'),
})
export const Route = createFileRoute('/posts')({
validateSearch: postSearchSchema,
})
Reading Search Params
function PostsComponent() {
const { page, filter, sort } = Route.useSearch()
const search = useSearch({ from: '/posts' })
}
Updating Search Params
import { useNavigate } from '@tanstack/react-router'
function Pagination() {
const navigate = useNavigate()
const { page } = Route.useSearch()
return (
<button
onClick={() =>
navigate({
search: (prev) => ({ ...prev, page: prev.page + 1 }),
})
}
>
Next Page
</button>
)
}
<Link
to="/posts"
search={(prev) => ({ ...prev, page: 2 })}
>
Page 2
</Link>
Search Param Options
const router = createRouter({
routeTree,
search: {
strict: true,
},
stringifySearch: defaultStringifySearch,
parseSearch: defaultParseSearch,
})
Navigation
Link Component
import { Link } from '@tanstack/react-router'
<Link to="/about">About</Link>
<Link to="/posts/$postId" params={{ postId: '123' }}>
Post 123
</Link>
<Link to="/posts" search={{ page: 2, filter: 'react' }}>
Page 2
</Link>
<Link
to="/posts"
activeProps={{ className: 'active' }}
inactiveProps={{ className: 'inactive' }}
activeOptions={{ exact: true }}
>
Posts
</Link>
<Link to="/posts" preload="intent">Posts</Link>
<Link to="/dashboard" preload="viewport">Dashboard</Link>
<Link to="/docs" hash="api-reference">API Reference</Link>
Programmatic Navigation
import { useNavigate, useRouter } from '@tanstack/react-router'
function MyComponent() {
const navigate = useNavigate()
const router = useRouter()
navigate({ to: '/posts', search: { page: 1 } })
navigate({ to: '/posts', replace: true })
navigate({ to: '.', search: (prev) => ({ ...prev, page: 2 }) })
router.history.back()
router.history.forward()
router.invalidate()
}
Redirects
import { redirect } from '@tanstack/react-router'
throw redirect({
to: '/login',
search: { redirect: location.href },
statusCode: 301,
})
Navigation Blocking
import { useBlocker } from '@tanstack/react-router'
function FormComponent() {
const [isDirty, setIsDirty] = useState(false)
useBlocker({
shouldBlockFn: () => isDirty,
withResolver: true,
})
const { proceed, reset, status } = useBlocker({
shouldBlockFn: () => isDirty,
})
if (status === 'blocked') {
return (
<div>
<p>Are you sure you want to leave?</p>
<button onClick={proceed}>Leave</button>
<button onClick={reset}>Stay</button>
</div>
)
}
}
Code Splitting
Automatic (File-Based Routing)
With file-based routing, create a lazy file:
routes/
posts.tsx # Critical: loader, beforeLoad, meta
posts.lazy.tsx # Lazy: component, pendingComponent, errorComponent
export const Route = createFileRoute('/posts')({
loader: () => fetchPosts(),
})
import { createLazyFileRoute } from '@tanstack/react-router'
export const Route = createLazyFileRoute('/posts')({
component: PostsComponent,
pendingComponent: PostsLoading,
errorComponent: PostsError,
})
Manual Code Splitting
const postsRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/posts',
loader: () => fetchPosts(),
}).lazy(() => import('./posts.lazy').then((d) => d.Route))
Preloading
const router = createRouter({
routeTree,
defaultPreload: 'intent',
defaultPreloadStaleTime: 30_000,
})
export const Route = createFileRoute('/posts/$postId')({
staleTime: 5_000,
preloadStaleTime: 30_000,
})
<Link to="/posts" preload="intent" preloadDelay={100}>
Posts
</Link>
Type Safety
Register Router Type
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
Type-Safe Hooks
All hooks are fully typed based on the route tree:
const { postId } = useParams({ from: '/posts/$postId' })
const { page } = useSearch({ from: '/posts' })
const data = useLoaderData({ from: '/posts/$postId' })
const { auth } = useRouteContext({ from: '/protected' })
Route Generics
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/posts/$postId')({
})
Authenticated Routes
export const Route = createRootRouteWithContext<{
auth: AuthContext
}>()({
component: RootComponent,
})
export const Route = createFileRoute('/_authenticated')({
beforeLoad: ({ context, location }) => {
if (!context.auth.isAuthenticated) {
throw redirect({
to: '/login',
search: { redirect: location.href },
})
}
},
})
export const Route = createFileRoute('/_authenticated/dashboard')({
component: Dashboard,
})
Scroll Restoration
const router = createRouter({
routeTree,
defaultScrollRestoration: true,
})
export const Route = createFileRoute('/posts')({
scrollRestoration: true,
})
<ScrollRestoration
getKey={(location) => location.pathname}
/>
Route Masking
Display a different URL than the actual route:
<Link
to="/photos/$photoId"
params={{ photoId: photo.id }}
mask={{ to: '/photos', search: { photoId: photo.id } }}
>
View Photo
</Link>
navigate({
to: '/photos/$photoId',
params: { photoId: photo.id },
mask: { to: '/photos', search: { photoId: photo.id } },
})
Not Found Handling
const router = createRouter({
routeTree,
defaultNotFoundComponent: () => <div>Page not found</div>,
})
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>,
})
Head Management
export const Route = createFileRoute('/posts/$postId')({
head: ({ loaderData }) => ({
meta: [
{ title: loaderData.title },
{ name: 'description', content: loaderData.excerpt },
{ property: 'og:title', content: loaderData.title },
],
links: [
{ rel: 'canonical', href: `https://example.com/posts/${loaderData.id}` },
],
}),
})
Integration with TanStack Query
import { queryOptions } from '@tanstack/react-query'
const postsQueryOptions = queryOptions({
queryKey: ['posts'],
queryFn: fetchPosts,
})
export const Route = createFileRoute('/posts')({
loader: ({ context: { queryClient } }) => {
return queryClient.ensureQueryData(postsQueryOptions)
},
component: PostsComponent,
})
function PostsComponent() {
const { data: posts } = useSuspenseQuery(postsQueryOptions)
return <PostsList posts={posts} />
}
Router Hooks Reference
| Hook | Purpose |
|---|
useRouter() | Access router instance |
useRouterState() | Subscribe to router state |
useParams() | Get route path params |
useSearch() | Get validated search params |
useLoaderData() | Get route loader data |
useRouteContext() | Get route context |
useNavigate() | Get navigate function |
useLocation() | Get current location |
useMatches() | Get all matched routes |
useMatch() | Get specific route match |
useBlocker() | Block navigation |
useLinkProps() | Get link props for custom components |
useMatchRoute() | Check if a route matches |
Best Practices
- Use file-based routing for most applications - it's simpler and auto-generates the route tree
- Validate search params with Zod or custom validators for type safety
- Use
loaderDeps to control when loaders re-execute based on search param changes
- Leverage context for dependency injection (QueryClient, auth state)
- Use
beforeLoad for authentication guards, not in components
- Separate critical vs lazy code - keep loaders in the main file, components in
.lazy.tsx
- Use
preload="intent" on Links for perceived performance
- Use
staleTime to prevent unnecessary refetches during navigation
- Register the router type for full TypeScript inference across the app
- Use
notFound() instead of conditional rendering for 404 states
- Colocate search param logic with routes that own them
- Use pathless layouts (
_authenticated) for shared auth/layout logic without URL segments
Common Pitfalls
- Forgetting to register the router type (
declare module)
- Not using
loaderDeps when loader depends on search params (causes stale data)
- Putting auth checks in components instead of
beforeLoad (flash of protected content)
- Not handling the loading state with
pendingComponent
- Using
useEffect for data fetching instead of route loaders
- Mutating search params directly instead of using navigate/Link
- Not wrapping the app with
RouterProvider
- Forgetting
getParentRoute in code-based route definitions