SKILL.md
$27
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:
// vite.config.ts
import { defineConfig } from 'vite'
import { TanStackRouterVite } from '@tanstack/router-plugin/vite'
export default defineConfig({
plugins: [
TanStackRouterVite(),
// ... other plugins
],
})
#### 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:
// routes/posts.$postId.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/posts/$postId')({
// Validation for path params
params: {
parse: (params) => ({ postId: Number(params.postId) }),
stringify: (params) => ({ postId: String(params.postId) }),
},
// Search params validation
validateSearch: (search: Record<string, unknown>) => {
return {
page: Number(search.page ?? 1),
filter: (search.filter as string) || '',
}
},
// Data loading
loader: async ({ params, context, abortController }) => {
return fetchPost(params.postId)
},
// Loader dependencies (re-run loader when these change)
loaderDeps: ({ search }) => ({ page: search.page }),
// Stale time for cached loader data
staleTime: 5_000,
// Preloading
preloadStaleTime: 30_000,
// Error component
errorComponent: PostErrorComponent,
// Pending/loading component
pendingComponent: PostLoadingComponent,
// 404 component
notFoundComponent: PostNotFoundComponent,
// Before load hook (authentication, redirects)
beforeLoad: async ({ context, location }) => {
if (!context.auth.isAuthenticated) {
throw redirect({
to: '/login',
search: { redirect: location.href },
})
}
},
// Head/meta management
head: () => ({
meta: [{ title: 'Post Details' }],
}),
// Component
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 }) => {
// Access router context (e.g., queryClient)
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:
// Create router with context
const router = createRouter({
routeTree,
context: {
queryClient,
auth: undefined!, // Will be provided by RouterProvider
},
})
// In root/app component
function App() {
const auth = useAuth()
return <RouterProvider router={router} context={{ auth }} />
}
// In routes
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,
// Or manual validation:
// validateSearch: (search) => postSearchSchema.parse(search),
})
Reading Search Params
function PostsComponent() {
// From route
const { page, filter, sort } = Route.useSearch()
// Or from any component with useSearch hook
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>
)
}
// Or via Link component
<Link
to="/posts"
search={(prev) => ({ ...prev, page: 2 })}
>
Page 2
</Link>
Search Param Options
const router = createRouter({
routeTree,
// Custom serialization
search: {
strict: true, // Reject unknown params
},
// Default search param serializer
stringifySearch: defaultStringifySearch,
parseSearch: defaultParseSearch,
})
Navigation
Link Component
import { Link } from '@tanstack/react-router'
// Static route
<Link to="/about">About</Link>
// Dynamic route with params
<Link to="/posts/$postId" params={{ postId: '123' }}>
Post 123
</Link>
// With search params
<Link to="/posts" search={{ page: 2, filter: 'react' }}>
Page 2
</Link>
// Active link styling
<Link
to="/posts"
activeProps={{ className: 'active' }}
inactiveProps={{ className: 'inactive' }}
activeOptions={{ exact: true }}
>
Posts
</Link>
// Preloading
<Link to="/posts" preload="intent">Posts</Link>
<Link to="/dashboard" preload="viewport">Dashboard</Link>
// Hash
<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 a route
navigate({ to: '/posts', search: { page: 1 } })
// Navigate with replace
navigate({ to: '/posts', replace: true })
// Relative navigation
navigate({ to: '.', search: (prev) => ({ ...prev, page: 2 }) })
// Go back/forward
router.history.back()
router.history.forward()
// Invalidate and reload current route
router.invalidate()
}
Redirects
import { redirect } from '@tanstack/react-router'
// In beforeLoad or loader
throw redirect({
to: '/login',
search: { redirect: location.href },
// Optional status code
statusCode: 301, // Permanent redirect (SSR)
})
Navigation Blocking
import { useBlocker } from '@tanstack/react-router'
function FormComponent() {
const [isDirty, setIsDirty] = useState(false)
useBlocker({
shouldBlockFn: () => isDirty,
withResolver: true, // Shows confirm dialog
})
// Or with custom UI
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
// posts.tsx (loaded eagerly)
export const Route = createFileRoute('/posts')({
loader: () => fetchPosts(),
})
// posts.lazy.tsx (loaded lazily)
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
// Router-level defaults
const router = createRouter({
routeTree,
defaultPreload: 'intent', // 'intent' | 'viewport' | 'render' | false
defaultPreloadStaleTime: 30_000, // 30 seconds
})
// Route-level
export const Route = createFileRoute('/posts/$postId')({
// Stale time for the loader data
staleTime: 5_000,
// How long preloaded data stays fresh
preloadStaleTime: 30_000,
})
// Link-level
<Link to="/posts" preload="intent" preloadDelay={100}>
Posts
</Link>
Type Safety
Register Router Type
// Declare module for type inference
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
Type-Safe Hooks
All hooks are fully typed based on the route tree:
// useParams - typed to route's params
const { postId } = useParams({ from: '/posts/$postId' })
// useSearch - typed to route's search schema
const { page } = useSearch({ from: '/posts' })
// useLoaderData - typed to loader return
const data = useLoaderData({ from: '/posts/$postId' })
// useRouteContext - typed to route context
const { auth } = useRouteContext({ from: '/protected' })
Route Generics
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/posts/$postId')({
// TypeScript infers:
// params: { postId: string }
// search: validated search schema type
// loaderData: return type of loader
// context: router context type
})
Authenticated Routes
// __root.tsx
export const Route = createRootRouteWithContext<{
auth: AuthContext
}>()({
component: RootComponent,
})
// _authenticated.tsx (pathless layout for auth)
export const Route = createFileRoute('/_authenticated')({
beforeLoad: ({ context, location }) => {
if (!context.auth.isAuthenticated) {
throw redirect({
to: '/login',
search: { redirect: location.href },
})
}
},
})
// _authenticated/dashboard.tsx
export const Route = createFileRoute('/_authenticated/dashboard')({
component: Dashboard, // Only accessible when authenticated
})
Scroll Restoration
const router = createRouter({
routeTree,
// Enable scroll restoration
defaultScrollRestoration: true,
})
// Or per-route
export const Route = createFileRoute('/posts')({
// Scroll to top on navigation
scrollRestoration: true,
})
// Custom scroll restoration key
<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>
// Or programmatically
navigate({
to: '/photos/$photoId',
params: { photoId: photo.id },
mask: { to: '/photos', search: { photoId: photo.id } },
})
Not Found Handling
// Global 404
const router = createRouter({
routeTree,
defaultNotFoundComponent: () => <div>Page not found</div>,
})
// Route-level 404
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 } }) => {
// Ensure data is in cache, won't refetch if fresh
return queryClient.ensureQueryData(postsQueryOptions)
},
component: PostsComponent,
})
function PostsComponent() {
// Use the same query options for reactive updates
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
loaderDepswhen 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
useEffectfor data fetching instead of route loaders
- Mutating search params directly instead of using navigate/Link
- Not wrapping the app with
RouterProvider
- Forgetting
getParentRoutein code-based route definitions