| name | tanstack-router-patterns |
| description | TanStack Router type-safe routing with file-based conventions, loaders, search params validation, and protected routes |
TanStack Router Patterns — Type-Safe Routing
Overview
TanStack Router patterns for type-safe React SPAs:
- File-based routing with code generation
- Type-safe params and search params
- Search params validated with Zod
- Data loading with TanStack Query integration
- Protected routes via
beforeLoad
- Lazy loading and code splitting
- Router context for dependency injection
Hard Rules
These rules are NON-NEGOTIABLE. Violating any of them is a bug.
- ALWAYS use file-based routing with the TanStack Router code generator
- ALWAYS use
Route.useParams() for type-safe route params — NEVER parse window.location
- ALWAYS validate search params with Zod schema via
validateSearch
- ALWAYS load data through route
loader + TanStack Query ensureQueryData
- ALWAYS use
useSuspenseQuery in components that have a loader — data is never undefined
- ALWAYS lazy load routes — code-split per route by default
- ALWAYS protect routes via
beforeLoad — NEVER use JSX wrapper components for auth guards
- ALWAYS pass QueryClient through router context — NEVER import it directly in route files
- NEVER use
useEffect for data fetching in routed components — use loaders
- NEVER use
react-router-dom in new code — TanStack Router is the standard
File-Based Routing Conventions
File Naming
| Pattern | Meaning | Example |
|---|
__root.tsx | Root layout route | routes/__root.tsx |
index.tsx | Index route for directory | routes/index.tsx → / |
$param.tsx | Dynamic segment | routes/users/$userId.tsx → /users/:userId |
_layout.tsx | Pathless layout (no URL segment) | routes/_authenticated.tsx |
_layout/ | Directory for layout children | routes/_authenticated/dashboard.tsx |
. (dot) | Nested path separator | routes/settings.profile.tsx → /settings/profile |
$.tsx | Splat/catch-all route | routes/$.tsx → /* |
Directory Structure
src/
routes/
__root.tsx # Root layout (nav, footer, providers)
index.tsx # / (home page)
about.tsx # /about
_authenticated.tsx # Layout: auth guard (no URL segment)
_authenticated/
dashboard.tsx # /dashboard (protected)
settings.tsx # /settings (protected)
settings.profile.tsx # /settings/profile (protected)
settings.notifications.tsx # /settings/notifications (protected)
users/
index.tsx # /users (list)
$userId.tsx # /users/:userId (detail)
$userId.edit.tsx # /users/:userId/edit
$.tsx # Catch-all / 404
routeTree.gen.ts # Auto-generated — NEVER edit manually
Code Generation Setup
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { TanStackRouterVite } from '@tanstack/router-plugin/vite';
export default defineConfig({
plugins: [
TanStackRouterVite(),
react(),
],
});
Rule: Never manually edit routeTree.gen.ts. It is auto-generated by the TanStack Router plugin.
Root Route Setup
Root Route with Context
import { createRootRouteWithContext, Outlet } from '@tanstack/react-router';
import type { QueryClient } from '@tanstack/react-query';
interface RouterContext {
queryClient: QueryClient;
}
export const Route = createRootRouteWithContext<RouterContext>()({
component: RootLayout,
notFoundComponent: NotFound,
});
function RootLayout(): React.ReactElement {
return (
<div className="min-h-screen flex flex-col">
<header>
<nav>{/* Navigation links */}</nav>
</header>
<main className="flex-1">
<Outlet />
</main>
<footer>{/* Footer */}</footer>
</div>
);
}
function NotFound(): React.ReactElement {
return (
<div className="flex items-center justify-center min-h-[50vh]">
<h1>404 — Page Not Found</h1>
</div>
);
}
Router Creation
import { createRouter } from '@tanstack/react-router';
import { routeTree } from '@/routeTree.gen';
import type { QueryClient } from '@tanstack/react-query';
export function createAppRouter(queryClient: QueryClient): ReturnType<typeof createRouter> {
return createRouter({
routeTree,
context: { queryClient },
defaultPreloadDelay: 0,
defaultPreload: 'intent',
});
}
declare module '@tanstack/react-router' {
interface Register {
router: ReturnType<typeof createAppRouter>;
}
}
App Entry Point
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { RouterProvider } from '@tanstack/react-router';
import { useState } from 'react';
import { createAppRouter } from '@/app/router';
function App(): React.ReactElement {
const [queryClient] = useState(() => new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60,
retry: 1,
},
},
}));
const [router] = useState(() => createAppRouter(queryClient));
return (
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
);
}
export default App;
Data Loading — Loader + useSuspenseQuery
The Pattern
Route loaders ensureQueryData (preload), then components use useSuspenseQuery (guaranteed data).
import { queryOptions } from '@tanstack/react-query';
import { fetchUser, fetchUsers } from './usersApi';
export const usersQueries = {
all: () =>
queryOptions({
queryKey: ['users'],
queryFn: fetchUsers,
}),
detail: (userId: string) =>
queryOptions({
queryKey: ['users', userId],
queryFn: () => fetchUser(userId),
}),
};
Route with Loader
import { createFileRoute } from '@tanstack/react-router';
import { useSuspenseQuery } from '@tanstack/react-query';
import { usersQueries } from '@/features/users/api/queries';
export const Route = createFileRoute('/users/$userId')({
loader: ({ context: { queryClient }, params: { userId } }) =>
queryClient.ensureQueryData(usersQueries.detail(userId)),
component: UserDetailPage,
});
function UserDetailPage(): React.ReactElement {
const { userId } = Route.useParams();
const { data: user } = useSuspenseQuery(usersQueries.detail(userId));
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}
List Route with Loader
import { createFileRoute } from '@tanstack/react-router';
import { useSuspenseQuery } from '@tanstack/react-query';
import { usersQueries } from '@/features/users/api/queries';
export const Route = createFileRoute('/users/')({
loader: ({ context: { queryClient } }) =>
queryClient.ensureQueryData(usersQueries.all()),
component: UsersListPage,
});
function UsersListPage(): React.ReactElement {
const { data: users } = useSuspenseQuery(usersQueries.all());
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
Type-Safe Search Params with Zod
Defining Search Params
import { createFileRoute } from '@tanstack/react-router';
import { z } from 'zod';
const usersSearchSchema = z.object({
page: z.coerce.number().int().positive().default(1).catch(1),
pageSize: z.coerce.number().int().min(10).max(100).default(20).catch(20),
search: z.string().optional().catch(undefined),
sortBy: z.enum(['name', 'email', 'createdAt']).default('name').catch('name'),
sortOrder: z.enum(['asc', 'desc']).default('asc').catch('asc'),
});
type UsersSearch = z.infer<typeof usersSearchSchema>;
export const Route = createFileRoute('/users/')({
validateSearch: usersSearchSchema,
loader: ({ context: { queryClient }, search }) =>
queryClient.ensureQueryData(usersQueries.list(search)),
component: UsersListPage,
});
Using Search Params in Components
function UsersListPage(): React.ReactElement {
const search = Route.useSearch();
const navigate = Route.useNavigate();
const { data: users } = useSuspenseQuery(usersQueries.list(search));
const setPage = (page: number): void => {
navigate({ search: (prev) => ({ ...prev, page }) });
};
const setSearch = (searchTerm: string): void => {
navigate({
search: (prev) => ({
...prev,
search: searchTerm || undefined,
page: 1,
}),
});
};
const setSortBy = (sortBy: UsersSearch['sortBy']): void => {
navigate({ search: (prev) => ({ ...prev, sortBy }) });
};
return (
<div>
<input
type="search"
defaultValue={search.search ?? ''}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search users..."
/>
<table>
<thead>
<tr>
<th>
<button onClick={() => setSortBy('name')}>Name</button>
</th>
<th>
<button onClick={() => setSortBy('email')}>Email</button>
</th>
</tr>
</thead>
<tbody>
{users.items.map((user) => (
<tr key={user.id}>
<td>{user.name}</td>
<td>{user.email}</td>
</tr>
))}
</tbody>
</table>
<Pagination
currentPage={search.page}
totalPages={users.totalPages}
onPageChange={setPage}
/>
</div>
);
}
Type-Safe Links with Search Params
import { Link } from '@tanstack/react-router';
<Link
to="/users"
search={{ page: 2, sortBy: 'name', sortOrder: 'desc' }}
>
View Users
</Link>
<Link
to="/users"
search={(prev) => ({ ...prev, page: prev.page + 1 })}
>
Next Page
</Link>
Protected Routes — beforeLoad
Auth Guard Layout
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router';
export const Route = createFileRoute('/_authenticated')({
beforeLoad: ({ context }) => {
const isAuthenticated = checkAuthState();
if (!isAuthenticated) {
throw redirect({
to: '/login',
search: {
redirect: location.href,
},
});
}
},
component: AuthenticatedLayout,
});
function AuthenticatedLayout(): React.ReactElement {
return (
<div className="flex">
<aside>{/* Sidebar for authenticated users */}</aside>
<div className="flex-1">
<Outlet />
</div>
</div>
);
}
Protected Route (Child of Auth Layout)
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/_authenticated/dashboard')({
loader: ({ context: { queryClient } }) =>
queryClient.ensureQueryData(dashboardQueries.summary()),
component: DashboardPage,
});
function DashboardPage(): React.ReactElement {
const { data } = useSuspenseQuery(dashboardQueries.summary());
return <div>{/* Dashboard content */}</div>;
}
Why beforeLoad, Not JSX Wrappers
function ProtectedRoute({ children }: { children: React.ReactNode }): React.ReactElement {
const { isAuthenticated } = useAuth();
if (!isAuthenticated) return <Navigate to="/login" />;
return <>{children}</>;
}
beforeLoad: ({ context }) => {
if (!context.auth.isAuthenticated) {
throw redirect({ to: '/login' });
}
}
Pending UI
Global Loading Indicator
import { useRouterState } from '@tanstack/react-router';
function GlobalPendingIndicator(): React.ReactElement | null {
const isLoading = useRouterState({ select: (s) => s.isLoading });
if (!isLoading) return null;
return (
<div className="fixed top-0 left-0 right-0 z-50">
<div className="h-1 bg-primary animate-pulse" />
</div>
);
}
Route-Level Pending
export const Route = createFileRoute('/users/$userId')({
loader: ({ context: { queryClient }, params: { userId } }) =>
queryClient.ensureQueryData(usersQueries.detail(userId)),
pendingComponent: UserDetailPending,
component: UserDetailPage,
});
function UserDetailPending(): React.ReactElement {
return (
<div className="animate-pulse space-y-4">
<div className="h-8 bg-muted rounded w-1/3" />
<div className="h-4 bg-muted rounded w-2/3" />
<div className="h-4 bg-muted rounded w-1/2" />
</div>
);
}
Not Found Handling
Route-Level Not Found
import { createFileRoute, notFound } from '@tanstack/react-router';
export const Route = createFileRoute('/users/$userId')({
loader: async ({ context: { queryClient }, params: { userId } }) => {
const user = await queryClient.ensureQueryData(usersQueries.detail(userId));
if (!user) {
throw notFound();
}
return user;
},
notFoundComponent: UserNotFound,
component: UserDetailPage,
});
function UserNotFound(): React.ReactElement {
return (
<div className="text-center py-12">
<h2>User not found</h2>
<p>The user you're looking for doesn't exist or has been removed.</p>
<Link to="/users">Back to Users</Link>
</div>
);
}
Catch-All 404
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/$')({
component: CatchAllNotFound,
});
function CatchAllNotFound(): React.ReactElement {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh]">
<h1 className="text-4xl font-bold">404</h1>
<p className="text-muted-foreground mt-2">Page not found</p>
<Link to="/" className="mt-4 underline">
Go home
</Link>
</div>
);
}
Lazy Loading
Default: All Routes Are Lazy
By default, TanStack Router with file-based routing code-splits every route. The component, loader, and other route options are lazy-loaded when the route is navigated to.
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/users/$userId')({
loader: ({ context: { queryClient }, params: { userId } }) =>
queryClient.ensureQueryData(usersQueries.detail(userId)),
component: UserDetailPage,
});
function UserDetailPage(): React.ReactElement {
return <div>{/* ... */}</div>;
}
Preloading on Intent
const router = createRouter({
routeTree,
context: { queryClient },
defaultPreload: 'intent',
defaultPreloadDelay: 0,
});
<Link to="/users/$userId" params={{ userId: '123' }}>
View User {/* Preloads route + data on hover */}
</Link>
Common Mistakes
❌ Fetching Data in useEffect
function UserPage(): React.ReactElement {
const [user, setUser] = useState<User | null>(null);
const { userId } = Route.useParams();
useEffect(() => {
fetchUser(userId).then(setUser);
}, [userId]);
if (!user) return <div>Loading...</div>;
return <div>{user.name}</div>;
}
export const Route = createFileRoute('/users/$userId')({
loader: ({ context: { queryClient }, params: { userId } }) =>
queryClient.ensureQueryData(usersQueries.detail(userId)),
component: UserPage,
});
function UserPage(): React.ReactElement {
const { userId } = Route.useParams();
const { data: user } = useSuspenseQuery(usersQueries.detail(userId));
return <div>{user.name}</div>;
}
❌ JSX Auth Wrappers Instead of beforeLoad
<Route element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
beforeLoad: ({ context }) => {
if (!context.auth.isAuthenticated) {
throw redirect({ to: '/login' });
}
}
❌ Importing QueryClient Directly in Routes
import { queryClient } from '@/lib/query-client';
loader: () => queryClient.ensureQueryData(...)
loader: ({ context: { queryClient } }) =>
queryClient.ensureQueryData(...)
Summary
- ✅ File-based routing with TanStack Router code generator
- ✅ Type-safe params via
Route.useParams()
- ✅ Search params validated with Zod +
validateSearch
- ✅ Data loading:
ensureQueryData in loader + useSuspenseQuery in component
- ✅ Protected routes via
beforeLoad + throw redirect()
- ✅ Lazy loading — code-split per route by default
- ✅ Router context for QueryClient injection
- ✅ Pending UI with
pendingComponent and useRouterState
- ✅ Not found handling with
notFound() + notFoundComponent