TanStack Router type-safe routing with file-based conventions, loaders, search params validation, and protected routes
Scanned 5/28/2026
Install via CLI
openskills install AppVerk/av-marketplace---
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
<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
</HARD-RULES>
---
## 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
```typescript
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { TanStackRouterVite } from '@tanstack/router-plugin/vite';
export default defineConfig({
plugins: [
TanStackRouterVite(), // Must be before react()
react(),
],
});
```
**Rule:** Never manually edit `routeTree.gen.ts`. It is auto-generated by the TanStack Router plugin.
---
## Root Route Setup
### Root Route with Context
```typescript
// src/routes/__root.tsx
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
```typescript
// src/app/router.ts
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', // Preload on hover
});
}
// Register router for type safety
declare module '@tanstack/react-router' {
interface Register {
router: ReturnType<typeof createAppRouter>;
}
}
```
### App Entry Point
```typescript
// src/app/App.tsx
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, // 1 minute
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).
```typescript
// src/features/users/api/queries.ts
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
```typescript
// src/routes/users/$userId.tsx
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));
// `user` is never undefined — loader guarantees data exists
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}
```
### List Route with Loader
```typescript
// src/routes/users/index.tsx
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
```typescript
// src/routes/users/index.tsx
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
```typescript
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, // Remove empty string from URL
page: 1, // Reset to page 1 on new search
}),
});
};
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
```typescript
import { Link } from '@tanstack/react-router';
// ✅ Type-safe — TypeScript validates search params
<Link
to="/users"
search={{ page: 2, sortBy: 'name', sortOrder: 'desc' }}
>
View Users
</Link>
// ✅ Preserve existing search params
<Link
to="/users"
search={(prev) => ({ ...prev, page: prev.page + 1 })}
>
Next Page
</Link>
```
---
## Protected Routes — beforeLoad
### Auth Guard Layout
```typescript
// src/routes/_authenticated.tsx
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router';
export const Route = createFileRoute('/_authenticated')({
beforeLoad: ({ context }) => {
// Check auth state — redirect if not authenticated
const isAuthenticated = checkAuthState(); // your auth check
if (!isAuthenticated) {
throw redirect({
to: '/login',
search: {
redirect: location.href, // Remember where user was going
},
});
}
},
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)
```typescript
// src/routes/_authenticated/dashboard.tsx
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 {
// This component only renders if auth guard passes
const { data } = useSuspenseQuery(dashboardQueries.summary());
return <div>{/* Dashboard content */}</div>;
}
```
### Why beforeLoad, Not JSX Wrappers
```typescript
// ❌ BAD: JSX wrapper for auth — causes flash of protected content
function ProtectedRoute({ children }: { children: React.ReactNode }): React.ReactElement {
const { isAuthenticated } = useAuth();
if (!isAuthenticated) return <Navigate to="/login" />;
return <>{children}</>;
}
// ✅ GOOD: beforeLoad — redirect happens BEFORE any component renders
beforeLoad: ({ context }) => {
if (!context.auth.isAuthenticated) {
throw redirect({ to: '/login' });
}
}
```
---
## Pending UI
### Global Loading Indicator
```typescript
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
```typescript
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
```typescript
// src/routes/users/$userId.tsx
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
```typescript
// src/routes/$.tsx
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.
```typescript
// src/routes/users/$userId.tsx
// This entire file is lazy-loaded when /users/:userId 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 {
// Component code is not in the main bundle
return <div>{/* ... */}</div>;
}
```
### Preloading on Intent
```typescript
// Router config enables preloading
const router = createRouter({
routeTree,
context: { queryClient },
defaultPreload: 'intent', // Preload on hover/focus
defaultPreloadDelay: 0, // No delay
});
// Links automatically preload on hover
<Link to="/users/$userId" params={{ userId: '123' }}>
View User {/* Preloads route + data on hover */}
</Link>
```
---
## Common Mistakes
### ❌ Fetching Data in useEffect
```typescript
// WRONG: useEffect + fetch in routed components
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>;
}
// CORRECT: Loader + useSuspenseQuery
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
```typescript
// WRONG: Causes flash of protected content
<Route element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
// CORRECT: Auth check before any component renders
beforeLoad: ({ context }) => {
if (!context.auth.isAuthenticated) {
throw redirect({ to: '/login' });
}
}
```
### ❌ Importing QueryClient Directly in Routes
```typescript
// WRONG: Direct import — hard to test, couples route to singleton
import { queryClient } from '@/lib/query-client';
loader: () => queryClient.ensureQueryData(...)
// CORRECT: QueryClient from router context — injectable, testable
loader: ({ context: { queryClient } }) =>
queryClient.ensureQueryData(...)
```
---
## Summary
1. ✅ File-based routing with TanStack Router code generator
2. ✅ Type-safe params via `Route.useParams()`
3. ✅ Search params validated with Zod + `validateSearch`
4. ✅ Data loading: `ensureQueryData` in loader + `useSuspenseQuery` in component
5. ✅ Protected routes via `beforeLoad` + `throw redirect()`
6. ✅ Lazy loading — code-split per route by default
7. ✅ Router context for QueryClient injection
8. ✅ Pending UI with `pendingComponent` and `useRouterState`
9. ✅ Not found handling with `notFound()` + `notFoundComponent`
No comments yet. Be the first to comment!