
Tanstack Router Best Practices
- 5.8k installs
- 205 repo stars
- Updated April 3, 2026
- deckardger/tanstack-agent-skills
tanstack-router-best-practices teaches type-safe TanStack Router patterns for React routing, loaders, search params, and navigation.
About
The tanstack-router-best-practices skill delivers comprehensive guidelines for implementing TanStack Router in React applications with emphasis on type safety, data loading, navigation, and route organization. Apply when setting up application routing, creating routes and layouts, implementing search parameter handling, configuring data loaders, enabling code splitting, integrating TanStack Query, or refactoring navigation patterns. Rule categories span CRITICAL type safety and route organization, HIGH router config, data loading, search params, and error handling, plus MEDIUM navigation, code splitting, and preloading, and LOW route context dependency injection. Quick reference prefixes include ts for global router inference and route context typing, org for file-based routing and pathless layouts, load for loaders with loaderDeps, ensureQueryData, deferred data, and parallel loading, search for validated params with middleware and serializers, nav for Link components and route masks, split for lazy routes and autoCodeSplitting, preload for intent-based loading, and ctx for root context injection. Each rule file documents explanation, bad examples, good examples, and contextual w.
- Prioritizes CRITICAL type safety and route organization rules with named prefixes.
- Covers loaders, ensureQueryData, deferred data, and TanStack Query integration patterns.
- Documents validated search params, middleware, serializers, and sensible defaults.
- Guides navigation with Link, active states, route masks, and programmatic navigate.
- Includes code splitting via lazy routes, preloading, and route context injection.
Tanstack Router Best Practices by the numbers
- 5,832 all-time installs (skills.sh)
- +108 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #88 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
tanstack-router-best-practices capabilities & compatibility
- Capabilities
- type safety rules for router registration and ro · route organization with file based routing and p · data loading patterns with loaders, deferred dat · search param validation, middleware, and custom · navigation, code splitting, preloading, and erro
- Use cases
- frontend · refactoring
What tanstack-router-best-practices says it does
Comprehensive guidelines for implementing TanStack Router patterns in React applications.
Integrating with TanStack Query
npx skills add https://github.com/deckardger/tanstack-agent-skills --skill tanstack-router-best-practicesAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 5.8k |
|---|---|
| repo stars | ★ 205 |
| Security audit | 3 / 3 scanners passed |
| Last updated | April 3, 2026 |
| Repository | deckardger/tanstack-agent-skills ↗ |
How do I implement TanStack Router correctly with type-safe loaders, search params, and navigation?
Implement type-safe TanStack Router patterns for React apps with loaders, search params, navigation, and TanStack Query integration.
Who is it for?
React teams adopting or refining TanStack Router with complex routing and TanStack Query integration.
Skip if: Skip for backend-only APIs, non-React frameworks, or apps without client-side routing needs.
When should I use this skill?
Setting up routing, creating routes and layouts, search param handling, loaders, code splitting, or refactoring navigation.
What you get
Maintainable route trees with validated URL state, optimized data loading, and fewer routing runtime errors.
- Typed root route context setup
- Refactored route files without global client imports
Files
TanStack Router Best Practices
Comprehensive guidelines for implementing TanStack Router patterns in React applications. These rules optimize type safety, data loading, navigation, and code organization.
When to Apply
- Setting up application routing
- Creating new routes and layouts
- Implementing search parameter handling
- Configuring data loaders
- Setting up code splitting
- Integrating with TanStack Query
- Refactoring navigation patterns
Rule Categories by Priority
| Priority | Category | Rules | Impact |
|---|---|---|---|
| CRITICAL | Type Safety | 4 rules | Prevents runtime errors and enables refactoring |
| CRITICAL | Route Organization | 5 rules | Ensures maintainable route structure |
| HIGH | Router Config | 1 rule | Global router defaults |
| HIGH | Data Loading | 6 rules | Optimizes data fetching and caching |
| HIGH | Search Params | 5 rules | Enables type-safe URL state |
| HIGH | Error Handling | 1 rule | Handles 404 and errors gracefully |
| MEDIUM | Navigation | 5 rules | Improves UX and accessibility |
| MEDIUM | Code Splitting | 3 rules | Reduces bundle size |
| MEDIUM | Preloading | 3 rules | Improves perceived performance |
| LOW | Route Context | 3 rules | Enables dependency injection |
Quick Reference
Type Safety (Prefix: ts-)
ts-register-router— Register router type for global inferencets-use-from-param— Usefromparameter for type narrowingts-route-context-typing— Type route context with createRootRouteWithContextts-query-options-loader— Use queryOptions in loaders for type inference
Router Config (Prefix: router-)
router-default-options— Configure router defaults (scrollRestoration, defaultErrorComponent, etc.)
Route Organization (Prefix: org-)
org-file-based-routing— Prefer file-based routing for conventionsorg-route-tree-structure— Follow hierarchical route tree patternsorg-pathless-layouts— Use pathless routes for shared layoutsorg-index-routes— Understand index vs layout routesorg-virtual-routes— Understand virtual file routes
Data Loading (Prefix: load-)
load-use-loaders— Use route loaders for data fetchingload-loader-deps— Define loaderDeps for cache controlload-ensure-query-data— Use ensureQueryData with TanStack Queryload-deferred-data— Split critical and non-critical dataload-error-handling— Handle loader errors appropriatelyload-parallel— Leverage parallel route loading
Search Params (Prefix: search-)
search-validation— Always validate search paramssearch-type-inheritance— Leverage parent search param typessearch-middleware— Use search param middlewaresearch-defaults— Provide sensible defaultssearch-custom-serializer— Configure custom search param serializers
Error Handling (Prefix: err-)
err-not-found— Handle not-found routes properly
Navigation (Prefix: nav-)
nav-link-component— Prefer Link component for navigationnav-active-states— Configure active link statesnav-use-navigate— Use useNavigate for programmatic navigationnav-relative-paths— Understand relative path navigationnav-route-masks— Use route masks for modal URLs
Code Splitting (Prefix: split-)
split-lazy-routes— Use .lazy.tsx for code splittingsplit-critical-path— Keep critical config in main route filesplit-auto-splitting— Enable autoCodeSplitting when possible
Preloading (Prefix: preload-)
preload-intent— Enable intent-based preloadingpreload-stale-time— Configure preload stale timepreload-manual— Use manual preloading strategically
Route Context (Prefix: ctx-)
ctx-root-context— Define context at root routectx-before-load— Extend context in beforeLoadctx-dependency-injection— Use context for dependency injection
How to Use
Each rule file in the rules/ directory contains: 1. Explanation — Why this pattern matters 2. Bad Example — Anti-pattern to avoid 3. Good Example — Recommended implementation 4. Context — When to apply or skip this rule
Full Reference
See individual rule files in rules/ directory for detailed guidance and code examples.
ctx-root-context: Define Context at Root Route
Priority: LOW
Explanation
Use createRootRouteWithContext to define typed context that flows through your entire route tree. This enables dependency injection for things like query clients, auth state, and services.
Bad Example
// No context - importing globals directly
// routes/__root.tsx
import { createRootRoute } from '@tanstack/react-router'
import { queryClient } from '@/lib/query-client' // Global import
export const Route = createRootRoute({
component: RootComponent,
})
// routes/posts.tsx
import { queryClient } from '@/lib/query-client' // Import again
export const Route = createFileRoute('/posts')({
loader: async () => {
// Using global - harder to test, couples to implementation
return queryClient.ensureQueryData(postQueries.list())
},
})Good Example
// routes/__root.tsx
import { createRootRouteWithContext, Outlet } from '@tanstack/react-router'
import { QueryClient } from '@tanstack/react-query'
// Define the context interface
interface RouterContext {
queryClient: QueryClient
auth: {
user: User | null
isAuthenticated: boolean
}
}
export const Route = createRootRouteWithContext<RouterContext>()({
component: RootComponent,
})
function RootComponent() {
return (
<>
<Header />
<main>
<Outlet />
</main>
<Footer />
</>
)
}
// router.tsx - Provide context when creating router
import { createRouter } from '@tanstack/react-router'
import { QueryClient } from '@tanstack/react-query'
import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query'
import { routeTree } from './routeTree.gen'
export function getRouter(auth: RouterContext['auth'] = { user: null, isAuthenticated: false }) {
const queryClient = new QueryClient()
const router = createRouter({
routeTree,
context: {
queryClient,
auth,
},
defaultPreload: 'intent',
defaultPreloadStaleTime: 0,
scrollRestoration: true,
})
setupRouterSsrQueryIntegration({ router, queryClient })
return router
}
// routes/posts.tsx - Use context in loaders
export const Route = createFileRoute('/posts')({
loader: async ({ context: { queryClient } }) => {
// Context is typed and injected
return queryClient.ensureQueryData(postQueries.list())
},
})Good Example: Auth-Protected Routes
// routes/__root.tsx
interface RouterContext {
queryClient: QueryClient
auth: AuthState
}
export const Route = createRootRouteWithContext<RouterContext>()({
component: RootComponent,
})
// routes/_authenticated.tsx - Layout route for protected pages
export const Route = createFileRoute('/_authenticated')({
beforeLoad: async ({ context, location }) => {
if (!context.auth.isAuthenticated) {
throw redirect({
to: '/login',
search: { redirect: location.href },
})
}
},
component: AuthenticatedLayout,
})
// routes/_authenticated/dashboard.tsx
export const Route = createFileRoute('/_authenticated/dashboard')({
loader: async ({ context: { queryClient, auth } }) => {
// We know user is authenticated from parent beforeLoad
return queryClient.ensureQueryData(
dashboardQueries.forUser(auth.user!.id)
)
},
})Extending Context with beforeLoad
// routes/posts/$postId.tsx
export const Route = createFileRoute('/posts/$postId')({
beforeLoad: async ({ context, params }) => {
// Extend context with route-specific data
const post = await fetchPost(params.postId)
return {
post, // Available to this route and children
}
},
loader: async ({ context }) => {
// context now includes 'post' from beforeLoad
const comments = await fetchComments(context.post.id)
return { comments }
},
})Context vs. Loader Data
| Context | Loader Data |
|---|---|
| Available in beforeLoad, loader, and component | Only available in component |
| Set at router creation or in beforeLoad | Returned from loader |
| Good for services, clients, auth | Good for route-specific data |
| Flows down to all children | Specific to route |
Context
- Type the context interface in
createRootRouteWithContext<T>() - Provide context when calling
createRouter({ context: {...} }) - Context flows from root to all nested routes
- Use
beforeLoadto extend context for specific subtrees - Enables testability - inject mocks in tests
- Avoids global imports and singletons
err-not-found: Handle Not-Found Routes Properly
Priority: HIGH
Explanation
Configure notFoundComponent to handle 404 errors gracefully. TanStack Router provides not-found handling at multiple levels: root, route-specific, and programmatic via notFound(). Proper configuration prevents blank screens and improves UX.
Bad Example
// No not-found handling - shows blank screen or error
const router = createRouter({
routeTree,
// Missing defaultNotFoundComponent
})
// Or throwing generic error
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) => {
const post = await fetchPost(params.postId)
if (!post) {
throw new Error('Not found') // Generic error, not proper 404
}
return post
},
})Good Example: Root-Level Not Found
// routes/__root.tsx
export const Route = createRootRoute({
component: RootComponent,
notFoundComponent: GlobalNotFound,
})
function GlobalNotFound() {
return (
<div className="not-found">
<h1>404 - Page Not Found</h1>
<p>The page you're looking for doesn't exist.</p>
<Link to="/">Go Home</Link>
</div>
)
}
// router.tsx - Can also set default
const router = createRouter({
routeTree,
defaultNotFoundComponent: () => (
<div>
<h1>404</h1>
<Link to="/">Return Home</Link>
</div>
),
})Good Example: Route-Specific Not Found
// routes/posts/$postId.tsx
import { createFileRoute, notFound } from '@tanstack/react-router'
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) => {
const post = await fetchPost(params.postId)
if (!post) {
throw notFound() // Proper 404 handling
}
return post
},
notFoundComponent: PostNotFound, // Custom 404 for this route
component: PostPage,
})
function PostNotFound() {
const { postId } = Route.useParams()
return (
<div>
<h1>Post Not Found</h1>
<p>No post exists with ID: {postId}</p>
<Link to="/posts">Browse all posts</Link>
</div>
)
}Good Example: Not Found with Data
export const Route = createFileRoute('/users/$username')({
loader: async ({ params }) => {
const user = await fetchUser(params.username)
if (!user) {
throw notFound({
// Pass data to notFoundComponent
data: {
username: params.username,
suggestions: await fetchSimilarUsernames(params.username),
},
})
}
return user
},
notFoundComponent: UserNotFound,
})
function UserNotFound() {
const { data } = Route.useMatch()
return (
<div>
<h1>User @{data?.username} not found</h1>
{data?.suggestions?.length > 0 && (
<div>
<p>Did you mean:</p>
<ul>
{data.suggestions.map((username) => (
<li key={username}>
<Link to="/users/$username" params={{ username }}>
@{username}
</Link>
</li>
))}
</ul>
</div>
)}
</div>
)
}Good Example: Catch-All Route
// routes/$.tsx - Catch-all splat route
export const Route = createFileRoute('/$')({
component: CatchAllNotFound,
})
function CatchAllNotFound() {
const { _splat } = Route.useParams()
return (
<div>
<h1>Page Not Found</h1>
<p>No page exists at: /{_splat}</p>
<Link to="/">Go to homepage</Link>
</div>
)
}Good Example: Nested Not Found Bubbling
// Not found bubbles up through route tree
// routes/posts.tsx
export const Route = createFileRoute('/posts')({
notFoundComponent: PostsNotFound, // Catches child 404s too
})
// routes/posts/$postId.tsx
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) => {
const post = await fetchPost(params.postId)
if (!post) throw notFound()
return post
},
// No notFoundComponent - bubbles to parent
})
// routes/posts/$postId/comments.tsx
export const Route = createFileRoute('/posts/$postId/comments')({
loader: async ({ params }) => {
const comments = await fetchComments(params.postId)
if (!comments) throw notFound() // Bubbles to /posts notFoundComponent
return comments
},
})Context
notFound()throws a special error caught by nearestnotFoundComponent- Not found bubbles up the route tree if not handled locally
- Use
defaultNotFoundComponenton router for global fallback - Pass data to
notFound({ data })for contextual 404 pages - Catch-all routes (
/$) can handle truly unknown paths - Different from error boundaries - specifically for 404 cases
load-ensure-query-data: Use ensureQueryData with TanStack Query
Priority: HIGH
Explanation
When integrating TanStack Router with TanStack Query, use queryClient.ensureQueryData() in loaders instead of prefetchQuery(). This respects the cache, awaits data if missing, and returns the data for potential use.
Bad Example
// Using prefetchQuery - doesn't return data, can't await stale check
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params, context: { queryClient } }) => {
// prefetchQuery never throws, swallows errors
queryClient.prefetchQuery({
queryKey: ['posts', params.postId],
queryFn: () => fetchPost(params.postId),
})
// No await - might not complete before render
// No return value to use
},
})
// Fetching directly - bypasses TanStack Query cache
export const Route = createFileRoute('/posts')({
loader: async () => {
const posts = await fetchPosts() // Not cached
return { posts }
},
})Good Example
// Define queryOptions for reuse
const postQueryOptions = (postId: string) =>
queryOptions({
queryKey: ['posts', postId],
queryFn: () => fetchPost(postId),
staleTime: 5 * 60 * 1000, // 5 minutes
})
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params, context: { queryClient } }) => {
// ensureQueryData:
// - Returns cached data if fresh
// - Fetches and caches if missing or stale
// - Awaits completion
// - Throws on error (caught by error boundary)
await queryClient.ensureQueryData(postQueryOptions(params.postId))
},
component: PostPage,
})
function PostPage() {
const { postId } = Route.useParams()
// Data guaranteed to exist from loader
const { data: post } = useSuspenseQuery(postQueryOptions(postId))
return <PostContent post={post} />
}Good Example: Multiple Parallel Queries
export const Route = createFileRoute('/dashboard')({
loader: async ({ context: { queryClient } }) => {
// Parallel data fetching
await Promise.all([
queryClient.ensureQueryData(statsQueries.overview()),
queryClient.ensureQueryData(activityQueries.recent()),
queryClient.ensureQueryData(notificationQueries.unread()),
])
},
})Good Example: Dependent Queries
export const Route = createFileRoute('/users/$userId/posts')({
loader: async ({ params, context: { queryClient } }) => {
// First query needed for second
const user = await queryClient.ensureQueryData(
userQueries.detail(params.userId)
)
// Dependent query uses result
await queryClient.ensureQueryData(
postQueries.byAuthor(user.id)
)
},
})Router Configuration for TanStack Query
// router.tsx
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute default
},
},
})
export const router = createRouter({
routeTree,
context: { queryClient },
// Let TanStack Query manage caching
defaultPreloadStaleTime: 0,
// SSR: Dehydrate query cache
dehydrate: () => ({
queryClientState: dehydrate(queryClient),
}),
// SSR: Hydrate on client
hydrate: (dehydrated) => {
hydrate(queryClient, dehydrated.queryClientState)
},
// Wrap with QueryClientProvider
Wrap: ({ children }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
),
})ensureQueryData vs prefetchQuery vs fetchQuery
| Method | Returns | Throws | Awaits | Use Case |
|---|---|---|---|---|
ensureQueryData | Data | Yes | Yes | Route loaders (recommended) |
prefetchQuery | void | No | Yes | Background prefetching |
fetchQuery | Data | Yes | Yes | When you need data immediately |
Context
ensureQueryDatais the recommended method for route loaders- Respects
staleTime- won't refetch fresh cached data - Errors propagate to route error boundaries
- Use
queryOptions()factory for type-safe, reusable query definitions - Set
defaultPreloadStaleTime: 0to let TanStack Query manage cache - Pair with
useSuspenseQueryin components for guaranteed data
load-parallel: Leverage Parallel Route Loading
Priority: MEDIUM
Explanation
TanStack Router loads nested route data in parallel, not sequentially. Structure your routes and loaders to maximize parallelization and avoid creating artificial waterfalls.
Bad Example
// Creating waterfall with dependent beforeLoad
export const Route = createFileRoute('/dashboard')({
beforeLoad: async () => {
const user = await fetchUser() // 200ms
const permissions = await fetchPermissions(user.id) // 200ms
const preferences = await fetchPreferences(user.id) // 200ms
// Total: 600ms (sequential)
return { user, permissions, preferences }
},
})
// Or nesting data dependencies incorrectly
// routes/posts.tsx
export const Route = createFileRoute('/posts')({
loader: async () => {
const posts = await fetchPosts() // 300ms
return { posts }
},
})
// routes/posts/$postId.tsx
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) => {
// Waits for parent to complete first - waterfall!
const post = await fetchPost(params.postId) // +200ms
return { post }
},
})Good Example: Parallel in Single Loader
export const Route = createFileRoute('/dashboard')({
beforeLoad: async () => {
// All requests start simultaneously
const [user, config] = await Promise.all([
fetchUser(), // 200ms
fetchAppConfig(), // 150ms
])
// Total: 200ms (parallel)
return { user, config }
},
loader: async ({ context }) => {
// These also run in parallel with each other
const [stats, activity, notifications] = await Promise.all([
fetchDashboardStats(context.user.id),
fetchRecentActivity(context.user.id),
fetchNotifications(context.user.id),
])
return { stats, activity, notifications }
},
})Good Example: Parallel Nested Routes
// Parent and child loaders run in PARALLEL
// routes/posts.tsx
export const Route = createFileRoute('/posts')({
loader: async () => {
// This runs...
const categories = await fetchCategories()
return { categories }
},
})
// routes/posts/$postId.tsx
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) => {
// ...at the SAME TIME as this!
const post = await fetchPost(params.postId)
const comments = await fetchComments(params.postId)
return { post, comments }
},
})
// Navigation to /posts/123:
// - Both loaders start simultaneously
// - Total time = max(categoriesTime, postTime + commentsTime)
// - NOT categoriesTime + postTime + commentsTimeGood Example: With TanStack Query
// routes/posts.tsx
export const Route = createFileRoute('/posts')({
loader: async ({ context: { queryClient } }) => {
// These all start in parallel
await Promise.all([
queryClient.ensureQueryData(postQueries.list()),
queryClient.ensureQueryData(categoryQueries.all()),
])
},
})
// routes/posts/$postId.tsx
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params, context: { queryClient } }) => {
// Runs in parallel with parent loader
await Promise.all([
queryClient.ensureQueryData(postQueries.detail(params.postId)),
queryClient.ensureQueryData(commentQueries.forPost(params.postId)),
])
},
})Good Example: Streaming Non-Critical Data
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params, context: { queryClient } }) => {
// Critical data - await
const post = await queryClient.ensureQueryData(
postQueries.detail(params.postId)
)
// Non-critical - start but don't await (stream in later)
queryClient.prefetchQuery(commentQueries.forPost(params.postId))
queryClient.prefetchQuery(relatedQueries.forPost(params.postId))
return { post }
},
component: PostPage,
})
function PostPage() {
const { post } = Route.useLoaderData()
const { postId } = Route.useParams()
// Critical data ready immediately
// Non-critical loads in component with loading state
const { data: comments, isLoading } = useQuery(
commentQueries.forPost(postId)
)
return (
<article>
<PostContent post={post} />
{isLoading ? <CommentsSkeleton /> : <Comments data={comments} />}
</article>
)
}Route Loading Timeline
Navigation to /posts/123
Without parallelization:
├─ beforeLoad (parent) ████████
├─ loader (parent) ████████
├─ beforeLoad (child) ████
├─ loader (child) ████████
└─ Render █
With parallelization:
├─ beforeLoad (parent) ████████
├─ beforeLoad (child) ████
├─ loader (parent) ████████
├─ loader (child) ████████████
└─ Render █Context
- Nested route loaders run in parallel by default
beforeLoadruns beforeloader(for auth, context setup)- Use
Promise.allfor parallel fetches within a single loader - Parent context is available in child loaders (after beforeLoad)
- Prefetch non-critical data without awaiting for streaming
- Monitor network tab to verify parallelization
load-use-loaders: Use Route Loaders for Data Fetching
Priority: HIGH
Explanation
Route loaders execute before the route renders, enabling data to be ready when the component mounts. This prevents loading waterfalls, enables preloading, and integrates with the router's caching layer.
Bad Example
// Fetching in component - creates waterfall
function PostsPage() {
const [posts, setPosts] = useState<Post[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
// Route renders, THEN data fetches, THEN UI updates
fetchPosts().then((data) => {
setPosts(data)
setLoading(false)
})
}, [])
if (loading) return <Loading />
return <PostList posts={posts} />
}
// No preloading possible - user sees loading state on navigationGood Example
// routes/posts.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/posts')({
loader: async () => {
const posts = await fetchPosts()
return { posts }
},
component: PostsPage,
})
function PostsPage() {
// Data is ready when component mounts - no loading state needed
const { posts } = Route.useLoaderData()
return <PostList posts={posts} />
}Good Example: With Parameters
// routes/posts/$postId.tsx
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) => {
// params are type-safe and guaranteed to exist
const post = await fetchPost(params.postId)
const comments = await fetchComments(params.postId)
return { post, comments }
},
component: PostDetailPage,
})
function PostDetailPage() {
const { post, comments } = Route.useLoaderData()
const { postId } = Route.useParams()
return (
<article>
<h1>{post.title}</h1>
<PostContent content={post.content} />
<CommentList comments={comments} />
</article>
)
}Good Example: With TanStack Query
// routes/posts/$postId.tsx
import { queryOptions } from '@tanstack/react-query'
const postQueryOptions = (postId: string) =>
queryOptions({
queryKey: ['posts', postId],
queryFn: () => fetchPost(postId),
})
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params, context: { queryClient } }) => {
// Ensure data is in cache before render
await queryClient.ensureQueryData(postQueryOptions(params.postId))
},
component: PostDetailPage,
})
function PostDetailPage() {
const { postId } = Route.useParams()
// useSuspenseQuery because loader guarantees data exists
const { data: post } = useSuspenseQuery(postQueryOptions(postId))
return <PostContent post={post} />
}Loader Context Properties
export const Route = createFileRoute('/posts')({
loader: async ({
params, // Route path parameters
context, // Route context (queryClient, auth, etc.)
abortController, // For cancelling stale requests
cause, // 'enter' | 'preload' | 'stay'
deps, // Dependencies from loaderDeps
preload, // Boolean: true if preloading
}) => {
// Use abortController for fetch cancellation
const response = await fetch('/api/posts', {
signal: abortController.signal,
})
// Different behavior for preload vs navigation
if (preload) {
// Lighter data for preload
return { posts: await response.json() }
}
// Full data for actual navigation
const posts = await response.json()
const stats = await fetchStats()
return { posts, stats }
},
})Context
- Loaders run during route matching, before component render
- Supports parallel loading across nested routes
- Enables preloading on link hover/focus
- Built-in stale-while-revalidate caching
- For complex caching needs, integrate with TanStack Query
- Use
beforeLoadfor auth checks and redirects
nav-link-component: Prefer Link Component for Navigation
Priority: MEDIUM
Explanation
Use the <Link> component for navigation instead of useNavigate() when possible. Links render proper <a> tags with valid href attributes, enabling right-click → open in new tab, better SEO, and accessibility.
Bad Example
// Using onClick with navigate - loses standard link behavior
function PostCard({ post }: { post: Post }) {
const navigate = useNavigate()
return (
<div
onClick={() => navigate({ to: '/posts/$postId', params: { postId: post.id } })}
className="post-card"
>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
</div>
)
}
// Problems:
// - No right-click → open in new tab
// - No cmd/ctrl+click for new tab
// - Not announced as link to screen readers
// - No valid href for SEOGood Example
import { Link } from '@tanstack/react-router'
function PostCard({ post }: { post: Post }) {
return (
<Link
to="/posts/$postId"
params={{ postId: post.id }}
className="post-card"
>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
</Link>
)
}
// Benefits:
// - Renders <a href="/posts/123">
// - Right-click menu works
// - Cmd/Ctrl+click opens new tab
// - Screen readers announce as link
// - Preloading works on hoverGood Example: With Search Params
function FilteredLink() {
return (
<Link
to="/products"
search={{ category: 'electronics', sort: 'price' }}
>
View Electronics
</Link>
)
}
// Preserving existing search params
function SortLink({ sort }: { sort: 'asc' | 'desc' }) {
return (
<Link
to="." // Current route
search={(prev) => ({ ...prev, sort })}
>
Sort {sort === 'asc' ? 'Ascending' : 'Descending'}
</Link>
)
}Good Example: With Active States
function NavLink({ to, children }: { to: string; children: React.ReactNode }) {
return (
<Link
to={to}
activeProps={{
className: 'nav-link-active',
'aria-current': 'page',
}}
inactiveProps={{
className: 'nav-link',
}}
activeOptions={{
exact: true, // Only active on exact match
}}
>
{children}
</Link>
)
}
// Or use render props for more control
function CustomNavLink({ to, children }: { to: string; children: React.ReactNode }) {
return (
<Link to={to}>
{({ isActive }) => (
<span className={isActive ? 'text-blue-600 font-bold' : 'text-gray-600'}>
{children}
{isActive && <CheckIcon className="ml-2" />}
</span>
)}
</Link>
)
}Good Example: With Preloading
function PostList({ posts }: { posts: Post[] }) {
return (
<ul>
{posts.map(post => (
<li key={post.id}>
<Link
to="/posts/$postId"
params={{ postId: post.id }}
preload="intent" // Preload on hover/focus
preloadDelay={100} // Wait 100ms before preloading
>
{post.title}
</Link>
</li>
))}
</ul>
)
}When to Use useNavigate Instead
// 1. After form submission
const createPost = useMutation({
mutationFn: submitPost,
onSuccess: (data) => {
navigate({ to: '/posts/$postId', params: { postId: data.id } })
},
})
// 2. After authentication
async function handleLogin(credentials: Credentials) {
await login(credentials)
navigate({ to: '/dashboard' })
}
// 3. Programmatic redirects
useEffect(() => {
if (!isAuthenticated) {
navigate({ to: '/login', search: { redirect: location.pathname } })
}
}, [isAuthenticated])Context
<Link>renders actual<a>tags with properhref- Supports all standard link behaviors (middle-click, cmd+click, etc.)
- Enables preloading on hover/focus
- Better for SEO - crawlers can follow links
- Reserve
useNavigatefor side effects and programmatic navigation - Use
<Navigate>component for immediate redirects on render
nav-route-masks: Use Route Masks for Modal URLs
Priority: LOW
Explanation
Route masks let you display one URL while internally routing to another. This is useful for modals, sheets, and overlays where you want a shareable URL that shows the modal, but navigating there directly should show the full page.
Bad Example
// Modal without proper URL handling
function PostList() {
const [selectedPost, setSelectedPost] = useState<string | null>(null)
return (
<div>
{posts.map(post => (
<div key={post.id} onClick={() => setSelectedPost(post.id)}>
{post.title}
</div>
))}
{selectedPost && (
<Modal onClose={() => setSelectedPost(null)}>
<PostDetail postId={selectedPost} />
</Modal>
)}
</div>
)
}
// Problems:
// - URL doesn't change when modal opens
// - Can't share link to modal
// - Back button doesn't close modal
// - Refresh loses modal stateGood Example: Route Masks for Modal
// routes/posts.tsx
export const Route = createFileRoute('/posts')({
component: PostList,
})
function PostList() {
const posts = usePosts()
return (
<div>
{posts.map(post => (
<Link
key={post.id}
to="/posts/$postId"
params={{ postId: post.id }}
mask={{
to: '/posts',
// URL shows /posts but routes to /posts/$postId
}}
>
{post.title}
</Link>
))}
<Outlet /> {/* Modal renders here */}
</div>
)
}
// routes/posts/$postId.tsx
export const Route = createFileRoute('/posts/$postId')({
component: PostModal,
})
function PostModal() {
const { postId } = Route.useParams()
const navigate = useNavigate()
return (
<Modal onClose={() => navigate({ to: '/posts' })}>
<PostDetail postId={postId} />
</Modal>
)
}
// User clicks post:
// - URL stays /posts (masked)
// - PostModal renders
// - Share link goes to /posts/$postId (real URL)
// - Direct navigation to /posts/$postId shows full page (no mask)Good Example: With Search Params
function PostList() {
return (
<div>
{posts.map(post => (
<Link
key={post.id}
to="/posts/$postId"
params={{ postId: post.id }}
mask={{
to: '/posts',
search: { modal: post.id }, // /posts?modal=123
}}
>
{post.title}
</Link>
))}
</div>
)
}Good Example: Programmatic Navigation with Mask
function PostCard({ post }: { post: Post }) {
const navigate = useNavigate()
const openInModal = () => {
navigate({
to: '/posts/$postId',
params: { postId: post.id },
mask: {
to: '/posts',
},
})
}
const openFullPage = () => {
navigate({
to: '/posts/$postId',
params: { postId: post.id },
// No mask - shows real URL
})
}
return (
<div>
<h3>{post.title}</h3>
<button onClick={openInModal}>Quick View</button>
<button onClick={openFullPage}>Full Page</button>
</div>
)
}Good Example: Unmask on Interaction
function PostModal() {
const { postId } = Route.useParams()
const navigate = useNavigate()
const expandToFullPage = () => {
// Navigate to real URL, removing mask
navigate({
to: '/posts/$postId',
params: { postId },
// No mask = real URL
replace: true, // Replace history entry
})
}
return (
<Modal>
<PostDetail postId={postId} />
<button onClick={expandToFullPage}>
Expand to full page
</button>
</Modal>
)
}Route Mask Behavior
| Scenario | URL Shown | Actual Route |
|---|---|---|
| Click masked link | Masked URL | Real route |
| Share/copy URL | Real URL | Real route |
| Direct navigation | Real URL | Real route |
| Browser refresh | Depends on URL in bar | Matches URL |
| Back button | Previous URL | Previous route |
Context
- Masks are client-side only - shared URLs are the real route
- Direct navigation to real URL bypasses mask (shows full page)
- Back button navigates through history correctly
- Use for modals, side panels, quick views
- Masks can include different search params
- Consider UX: users expect shared URLs to work
org-virtual-routes: Understand Virtual File Routes
Priority: LOW
Explanation
Virtual routes are automatically generated placeholder routes in the route tree when you have a .lazy.tsx file without a corresponding main route file. They provide the minimal configuration needed to anchor lazy-loaded components.
Bad Example
// Creating unnecessary boilerplate main route files
// routes/settings.tsx - Just to have a file
export const Route = createFileRoute('/settings')({
// Empty - no loader, no beforeLoad, nothing
})
// routes/settings.lazy.tsx - Actual component
export const Route = createLazyFileRoute('/settings')({
component: SettingsPage,
})
// The main file is unnecessary boilerplateGood Example: Let Virtual Routes Handle It
// Delete routes/settings.tsx entirely!
// routes/settings.lazy.tsx - Only file needed
export const Route = createLazyFileRoute('/settings')({
component: SettingsPage,
pendingComponent: SettingsLoading,
errorComponent: SettingsError,
})
function SettingsPage() {
return <div>Settings Content</div>
}
// TanStack Router auto-generates a virtual route:
// {
// path: '/settings',
// // Minimal config to anchor the lazy file
// }Good Example: When You DO Need Main Route File
// routes/dashboard.tsx - Need this for loader/beforeLoad
export const Route = createFileRoute('/dashboard')({
beforeLoad: async ({ context }) => {
if (!context.auth.isAuthenticated) {
throw redirect({ to: '/login' })
}
},
loader: async ({ context: { queryClient } }) => {
await queryClient.ensureQueryData(dashboardQueries.stats())
},
// Component is in lazy file
})
// routes/dashboard.lazy.tsx
export const Route = createLazyFileRoute('/dashboard')({
component: DashboardPage,
pendingComponent: DashboardSkeleton,
})
// Main file IS needed here because we have loader/beforeLoadDecision Guide
| Route Has... | Need Main File? | Use Virtual? |
|---|---|---|
| Only component | No | Yes |
| loader | Yes | No |
| beforeLoad | Yes | No |
| validateSearch | Yes | No |
| loaderDeps | Yes | No |
| Just pendingComponent/errorComponent | No | Yes |
Good Example: File Structure with Virtual Routes
routes/
├── __root.tsx # Always needed
├── index.tsx # Has loader
├── about.lazy.tsx # Virtual route (no main file)
├── contact.lazy.tsx # Virtual route (no main file)
├── dashboard.tsx # Has beforeLoad (auth)
├── dashboard.lazy.tsx # Component
├── posts.tsx # Has loader
├── posts.lazy.tsx # Component
├── posts/
│ ├── $postId.tsx # Has loader
│ └── $postId.lazy.tsx # Component
└── settings/
├── index.lazy.tsx # Virtual route
├── profile.lazy.tsx # Virtual route
└── security.tsx # Has beforeLoad (requires re-auth)Good Example: Generated Route Tree
// routeTree.gen.ts (auto-generated)
import { Route as rootRoute } from './routes/__root'
import { Route as aboutLazyRoute } from './routes/about.lazy' // Virtual parent
export const routeTree = rootRoute.addChildren([
// Virtual route created for about.lazy.tsx
createRoute({
path: '/about',
getParentRoute: () => rootRoute,
}).lazy(() => import('./routes/about.lazy').then(m => m.Route)),
// Regular route with explicit main file
dashboardRoute.addChildren([...]),
])Context
- Virtual routes reduce boilerplate for simple pages
- Only works with file-based routing
- Auto-generated in
routeTree.gen.ts - Main route file needed for any "critical path" config
- Critical: loader, beforeLoad, validateSearch, loaderDeps, context
- Non-critical (can be in lazy): component, pendingComponent, errorComponent
- Check generated route tree to verify virtual routes
preload-intent: Enable Intent-Based Preloading
Priority: MEDIUM
Explanation
Configure defaultPreload: 'intent' to preload routes when users hover or focus links. This loads data before the click, making navigation feel instant.
Bad Example
// No preloading configured - data loads after click
const router = createRouter({
routeTree,
// No defaultPreload - user waits after every navigation
})
// Each navigation shows loading state
function PostList({ posts }: { posts: Post[] }) {
return (
<ul>
{posts.map(post => (
<li key={post.id}>
<Link to="/posts/$postId" params={{ postId: post.id }}>
{post.title}
</Link>
{/* Click → wait for data → render */}
</li>
))}
</ul>
)
}Good Example
// router.tsx - Enable preloading by default
const router = createRouter({
routeTree,
defaultPreload: 'intent', // Preload on hover/focus
defaultPreloadDelay: 50, // Wait 50ms before starting
})
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
// Links automatically preload on hover
function PostList({ posts }: { posts: Post[] }) {
return (
<ul>
{posts.map(post => (
<li key={post.id}>
<Link to="/posts/$postId" params={{ postId: post.id }}>
{post.title}
</Link>
{/* Hover → preload starts → click → instant navigation */}
</li>
))}
</ul>
)
}Preload Options
// Router-level defaults
const router = createRouter({
routeTree,
defaultPreload: 'intent', // 'intent' | 'render' | 'viewport' | false
defaultPreloadDelay: 50, // ms before preload starts
defaultPreloadStaleTime: 30000, // 30s - how long preloaded data stays fresh
})
// Link-level overrides
<Link
to="/heavy-page"
preload={false} // Disable for this specific link
>
Heavy Page
</Link>
<Link
to="/critical-page"
preload="render" // Preload immediately when Link renders
>
Critical Page
</Link>Preload Strategies
| Strategy | Behavior | Use Case |
|---|---|---|
'intent' | Preload on hover/focus | Default for most links |
'render' | Preload when Link mounts | Critical next pages |
'viewport' | Preload when Link enters viewport | Below-fold content |
false | No preloading | Heavy, rarely-visited pages |
Good Example: With TanStack Query Integration
// When using TanStack Query, disable router cache
const router = createRouter({
routeTree,
defaultPreload: 'intent',
defaultPreloadStaleTime: 0, // Let TanStack Query manage cache
context: {
queryClient,
},
})
// Route loader uses TanStack Query
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params, context: { queryClient } }) => {
// ensureQueryData respects TanStack Query's staleTime
await queryClient.ensureQueryData(postQueries.detail(params.postId))
},
})Context
- Preloading loads route code AND executes loaders
preloadDelayprevents excessive requests on quick mouse movements- Preloaded data is garbage collected after
preloadStaleTime - Works with both router caching and external caching (TanStack Query)
- Mobile: Consider
'viewport'since hover isn't available - Monitor network tab to verify preloading works correctly
router-default-options: Configure Router Default Options
Priority: HIGH
Explanation
TanStack Router's createRouter accepts several default options that apply globally. Configure these for consistent behavior across your application including error handling, scroll restoration, and performance optimizations.
Bad Example
// Minimal router - missing useful defaults
const router = createRouter({
routeTree,
context: { queryClient },
})
// Each route must handle its own errors
// No scroll restoration on navigation
// No preloading configuredGood Example: Full Configuration
import { QueryClient } from '@tanstack/react-query'
import { createRouter } from '@tanstack/react-router'
import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query'
import { routeTree } from './routeTree.gen'
import { DefaultCatchBoundary } from '@/components/DefaultCatchBoundary'
import { DefaultNotFound } from '@/components/DefaultNotFound'
export function getRouter() {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
staleTime: 1000 * 60 * 2,
},
},
})
const router = createRouter({
routeTree,
context: { queryClient, user: null },
// Preloading
defaultPreload: 'intent', // Preload on hover/focus
defaultPreloadStaleTime: 0, // Let Query manage freshness
// Error handling
defaultErrorComponent: DefaultCatchBoundary,
defaultNotFoundComponent: DefaultNotFound,
// UX
scrollRestoration: true, // Restore scroll on back/forward
// Performance
defaultStructuralSharing: true, // Optimize re-renders
})
setupRouterSsrQueryIntegration({
router,
queryClient,
})
return router
}Good Example: DefaultCatchBoundary Component
// components/DefaultCatchBoundary.tsx
import { ErrorComponent, useRouter } from '@tanstack/react-router'
export function DefaultCatchBoundary({ error }: { error: Error }) {
const router = useRouter()
return (
<div className="error-container">
<h1>Something went wrong</h1>
<ErrorComponent error={error} />
<button onClick={() => router.invalidate()}>
Try again
</button>
</div>
)
}Good Example: DefaultNotFound Component
// components/DefaultNotFound.tsx
import { Link } from '@tanstack/react-router'
export function DefaultNotFound() {
return (
<div className="not-found-container">
<h1>404 - Page Not Found</h1>
<p>The page you're looking for doesn't exist.</p>
<Link to="/">Go home</Link>
</div>
)
}Router Options Reference
| Option | Type | Default | Description |
|---|---|---|---|
defaultPreload | `false \ | 'intent' \ | 'render' \ |
defaultPreloadStaleTime | number | 30000 | How long preloaded data stays fresh (ms) |
defaultErrorComponent | Component | Built-in | Global error boundary |
defaultNotFoundComponent | Component | Built-in | Global 404 page |
scrollRestoration | boolean | false | Restore scroll on navigation |
defaultStructuralSharing | boolean | true | Optimize loader data re-renders |
Good Example: Route-Level Overrides
// Routes can override defaults
export const Route = createFileRoute('/admin')({
// Custom error handling for admin section
errorComponent: AdminErrorBoundary,
notFoundComponent: AdminNotFound,
// Disable preload for sensitive routes
preload: false,
})Good Example: With Pending Component
const router = createRouter({
routeTree,
context: { queryClient },
defaultPreload: 'intent',
defaultPreloadStaleTime: 0,
defaultErrorComponent: DefaultCatchBoundary,
defaultNotFoundComponent: DefaultNotFound,
scrollRestoration: true,
// Show during route transitions
defaultPendingComponent: () => (
<div className="loading-bar" />
),
defaultPendingMinMs: 200, // Min time to show pending UI
defaultPendingMs: 1000, // Delay before showing pending UI
})Context
- Set
defaultPreloadStaleTime: 0when using TanStack Query scrollRestoration: trueimproves back/forward navigation UXdefaultStructuralSharingprevents unnecessary re-renders- Route-level options override router defaults
- Error/NotFound components receive route context
- Pending components help with perceived performance
search-custom-serializer: Configure Custom Search Param Serializers
Priority: LOW
Explanation
By default, TanStack Router serializes search params as JSON. For cleaner URLs or compatibility with external systems, you can provide custom serializers using libraries like qs, query-string, or your own implementation.
Bad Example
// Default JSON serialization creates ugly URLs
// URL: /products?filters=%7B%22category%22%3A%22electronics%22%2C%22inStock%22%3Atrue%7D
// Or manually parsing/serializing inconsistently
function ProductList() {
const searchParams = new URLSearchParams(window.location.search)
const filters = JSON.parse(searchParams.get('filters') || '{}')
// Inconsistent with router's handling
}Good Example: Using JSURL for Compact URLs
import { createRouter } from '@tanstack/react-router'
import JSURL from 'jsurl2'
const router = createRouter({
routeTree,
search: {
// Custom serializer for compact, URL-safe encoding
serialize: (search) => JSURL.stringify(search),
parse: (searchString) => JSURL.parse(searchString) || {},
},
})
// URL: /products?~(category~'electronics~inStock~true)
// Much shorter than JSON!Good Example: Using query-string for Flat Params
import { createRouter } from '@tanstack/react-router'
import queryString from 'query-string'
const router = createRouter({
routeTree,
search: {
serialize: (search) =>
queryString.stringify(search, {
arrayFormat: 'bracket',
skipNull: true,
}),
parse: (searchString) =>
queryString.parse(searchString, {
arrayFormat: 'bracket',
parseBooleans: true,
parseNumbers: true,
}),
},
})
// URL: /products?category=electronics&inStock=true&tags[]=sale&tags[]=new
// Traditional query string formatGood Example: Using qs for Nested Objects
import { createRouter } from '@tanstack/react-router'
import qs from 'qs'
const router = createRouter({
routeTree,
search: {
serialize: (search) =>
qs.stringify(search, {
encodeValuesOnly: true,
arrayFormat: 'brackets',
}),
parse: (searchString) =>
qs.parse(searchString, {
ignoreQueryPrefix: true,
decoder(value) {
// Parse booleans and numbers
if (value === 'true') return true
if (value === 'false') return false
if (/^-?\d+$/.test(value)) return parseInt(value, 10)
return value
},
}),
},
})
// URL: /products?filters[category]=electronics&filters[price][min]=100&filters[price][max]=500Good Example: Base64 for Complex State
import { createRouter } from '@tanstack/react-router'
const router = createRouter({
routeTree,
search: {
serialize: (search) => {
if (Object.keys(search).length === 0) return ''
const json = JSON.stringify(search)
return btoa(json) // Base64 encode
},
parse: (searchString) => {
if (!searchString) return {}
try {
return JSON.parse(atob(searchString)) // Base64 decode
} catch {
return {}
}
},
},
})
// URL: /products?eyJjYXRlZ29yeSI6ImVsZWN0cm9uaWNzIn0
// Opaque but compactGood Example: Hybrid Approach
// Some params as regular query, complex ones as JSON
import { createRouter } from '@tanstack/react-router'
const router = createRouter({
routeTree,
search: {
serialize: (search) => {
const { filters, ...simple } = search
const params = new URLSearchParams()
// Simple values as regular params
Object.entries(simple).forEach(([key, value]) => {
if (value !== undefined) {
params.set(key, String(value))
}
})
// Complex filters as JSON
if (filters && Object.keys(filters).length > 0) {
params.set('filters', JSON.stringify(filters))
}
return params.toString()
},
parse: (searchString) => {
const params = new URLSearchParams(searchString)
const result: Record<string, unknown> = {}
params.forEach((value, key) => {
if (key === 'filters') {
result.filters = JSON.parse(value)
} else if (value === 'true') {
result[key] = true
} else if (value === 'false') {
result[key] = false
} else if (/^-?\d+$/.test(value)) {
result[key] = parseInt(value, 10)
} else {
result[key] = value
}
})
return result
},
},
})
// URL: /products?page=1&sort=price&filters={"category":"electronics","inStock":true}Serializer Comparison
| Library | URL Style | Best For |
|---|---|---|
| Default (JSON) | ?data=%7B...%7D | TypeScript safety |
| jsurl2 | ?~(key~'value) | Compact, readable |
| query-string | ?key=value&arr[]=1 | Traditional APIs |
| qs | ?obj[nested]=value | Deep nesting |
| Base64 | ?eyJrZXkiOiJ2YWx1ZSJ9 | Opaque, compact |
Context
- Custom serializers apply globally to all routes
- Route-level
validateSearchstill works after parsing - Consider URL length limits (~2000 chars for safe cross-browser)
- SEO: Search engines may not understand custom formats
- Bookmarkability: Users can't easily modify opaque URLs
- Debugging: JSON is easier to read in browser devtools
search-validation: Always Validate Search Params
Priority: HIGH
Explanation
Search params come from the URL - user-controlled input that must be validated. Use validateSearch to parse, validate, and provide defaults. This ensures type safety and prevents runtime errors from malformed URLs.
Bad Example
// No validation - trusting URL input directly
export const Route = createFileRoute('/products')({
component: ProductsPage,
})
function ProductsPage() {
// Accessing raw search params without validation
const searchParams = new URLSearchParams(window.location.search)
const page = parseInt(searchParams.get('page') || '1') // Could be NaN
const sort = searchParams.get('sort') as 'asc' | 'desc' // Could be anything
// Runtime errors possible if URL is malformed
return <ProductList page={page} sort={sort} />
}Good Example: Manual Validation
export const Route = createFileRoute('/products')({
validateSearch: (search: Record<string, unknown>) => {
return {
page: Number(search.page) || 1,
sort: search.sort === 'desc' ? 'desc' : 'asc',
category: typeof search.category === 'string' ? search.category : undefined,
minPrice: Number(search.minPrice) || undefined,
maxPrice: Number(search.maxPrice) || undefined,
}
},
component: ProductsPage,
})
function ProductsPage() {
// Fully typed, validated search params
const { page, sort, category, minPrice, maxPrice } = Route.useSearch()
// page: number (default 1)
// sort: 'asc' | 'desc' (default 'asc')
// category: string | undefined
}Good Example: With Zod
import { z } from 'zod'
const productSearchSchema = z.object({
page: z.number().min(1).catch(1),
limit: z.number().min(1).max(100).catch(20),
sort: z.enum(['name', 'price', 'date']).catch('name'),
order: z.enum(['asc', 'desc']).catch('asc'),
category: z.string().optional(),
search: z.string().optional(),
minPrice: z.number().min(0).optional(),
maxPrice: z.number().min(0).optional(),
})
type ProductSearch = z.infer<typeof productSearchSchema>
export const Route = createFileRoute('/products')({
validateSearch: (search) => productSearchSchema.parse(search),
component: ProductsPage,
})
function ProductsPage() {
const search = Route.useSearch()
// search: ProductSearch - fully typed with defaults
return (
<ProductList
page={search.page}
limit={search.limit}
sort={search.sort}
order={search.order}
filters={{
category: search.category,
search: search.search,
priceRange: search.minPrice && search.maxPrice
? [search.minPrice, search.maxPrice]
: undefined,
}}
/>
)
}Good Example: With Valibot
import * as v from 'valibot'
import { valibotSearchValidator } from '@tanstack/router-valibot-adapter'
const searchSchema = v.object({
page: v.fallback(v.number(), 1),
query: v.fallback(v.string(), ''),
filters: v.fallback(
v.array(v.string()),
[]
),
})
export const Route = createFileRoute('/search')({
validateSearch: valibotSearchValidator(searchSchema),
component: SearchPage,
})Updating Search Params
function ProductFilters() {
const navigate = useNavigate()
const search = Route.useSearch()
const updateFilters = (newFilters: Partial<ProductSearch>) => {
navigate({
to: '.', // Current route
search: (prev) => ({
...prev,
...newFilters,
page: 1, // Reset to page 1 when filters change
}),
})
}
return (
<div>
<select
value={search.sort}
onChange={(e) => updateFilters({ sort: e.target.value as ProductSearch['sort'] })}
>
<option value="name">Name</option>
<option value="price">Price</option>
<option value="date">Date</option>
</select>
</div>
)
}Context
- Search params are user input - never trust them unvalidated
- Use
.catch()in Zod orfallback()in Valibot for graceful defaults - Validation runs on every navigation - keep it fast
- Search params are inherited by child routes
- Use
searchupdater function to preserve other params
split-lazy-routes: Use .lazy.tsx for Code Splitting
Priority: MEDIUM
Explanation
Split route components into .lazy.tsx files to reduce initial bundle size. The main route file keeps critical configuration (path, loaders, search validation), while lazy files contain components that load on-demand.
Bad Example
// routes/dashboard.tsx - Everything in one file
import { createFileRoute } from '@tanstack/react-router'
import { HeavyChartLibrary } from 'heavy-chart-library'
import { ComplexDataGrid } from 'complex-data-grid'
import { AnalyticsWidgets } from './components/AnalyticsWidgets'
export const Route = createFileRoute('/dashboard')({
loader: async ({ context }) => {
return context.queryClient.ensureQueryData(dashboardQueries.stats())
},
component: DashboardPage, // Entire component in main bundle
})
function DashboardPage() {
// Heavy components loaded even if user never visits dashboard
return (
<div>
<HeavyChartLibrary data={useLoaderData()} />
<ComplexDataGrid />
<AnalyticsWidgets />
</div>
)
}Good Example
// routes/dashboard.tsx - Only critical config
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/dashboard')({
loader: async ({ context }) => {
return context.queryClient.ensureQueryData(dashboardQueries.stats())
},
// No component - it's in the lazy file
})
// routes/dashboard.lazy.tsx - Lazy-loaded component
import { createLazyFileRoute } from '@tanstack/react-router'
import { HeavyChartLibrary } from 'heavy-chart-library'
import { ComplexDataGrid } from 'complex-data-grid'
import { AnalyticsWidgets } from './components/AnalyticsWidgets'
export const Route = createLazyFileRoute('/dashboard')({
component: DashboardPage,
pendingComponent: DashboardSkeleton,
errorComponent: DashboardError,
})
function DashboardPage() {
const data = Route.useLoaderData()
return (
<div>
<HeavyChartLibrary data={data} />
<ComplexDataGrid />
<AnalyticsWidgets />
</div>
)
}
function DashboardSkeleton() {
return <div className="dashboard-skeleton">Loading dashboard...</div>
}
function DashboardError({ error }: { error: Error }) {
return <div>Failed to load dashboard: {error.message}</div>
}What Goes Where
// Main route file (routes/example.tsx)
// - path configuration (implicit from file location)
// - validateSearch
// - beforeLoad (auth checks, redirects)
// - loader (data fetching)
// - loaderDeps
// - context manipulation
// - Static route data
// Lazy file (routes/example.lazy.tsx)
// - component
// - pendingComponent
// - errorComponent
// - notFoundComponentUsing getRouteApi in Lazy Components
// routes/posts/$postId.lazy.tsx
import { createLazyFileRoute, getRouteApi } from '@tanstack/react-router'
const route = getRouteApi('/posts/$postId')
export const Route = createLazyFileRoute('/posts/$postId')({
component: PostPage,
})
function PostPage() {
// Type-safe access without importing main route file
const { postId } = route.useParams()
const data = route.useLoaderData()
return <article>{/* ... */}</article>
}Automatic Code Splitting
// vite.config.ts - Enable automatic splitting
import { TanStackRouterVite } from '@tanstack/router-plugin/vite'
export default defineConfig({
plugins: [
TanStackRouterVite({
autoCodeSplitting: true, // Automatically splits all route components
}),
react(),
],
})
// With autoCodeSplitting, you don't need .lazy.tsx files
// The plugin handles the splitting automaticallyContext
- Lazy loading reduces initial bundle size significantly
- Loaders are NOT lazy - they need to run before rendering
createLazyFileRouteonly accepts component-related options- Use
getRouteApi()for type-safe hook access in lazy files - Consider
autoCodeSplitting: truefor simpler setup - Virtual routes auto-generate when only .lazy.tsx exists
ts-register-router: Register Router Type for Global Inference
Priority: CRITICAL
Explanation
Register your router instance with TypeScript's module declaration to enable type inference across your entire application. Without registration, hooks like useNavigate, useParams, and useSearch won't know your route structure.
Bad Example
// router.tsx - Missing type registration
import { createRouter, createRootRoute } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
export const router = createRouter({ routeTree })
// components/Navigation.tsx
import { useNavigate } from '@tanstack/react-router'
function Navigation() {
const navigate = useNavigate()
// TypeScript doesn't know valid routes - no autocomplete or type checking
navigate({ to: '/posts/$postId' }) // No error even if route doesn't exist
}Good Example
// router.tsx
import { createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
export const router = createRouter({ routeTree })
// Register the router instance for type inference
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
// components/Navigation.tsx
import { useNavigate } from '@tanstack/react-router'
function Navigation() {
const navigate = useNavigate()
// Full type safety - TypeScript knows all valid routes
navigate({ to: '/posts/$postId', params: { postId: '123' } })
// Type error if route doesn't exist
navigate({ to: '/invalid-route' }) // Error: Type '"/invalid-route"' is not assignable...
// Autocomplete for params
navigate({
to: '/users/$userId/posts/$postId',
params: { userId: '1', postId: '2' }, // Both required
})
}Benefits of Registration
// After registration, all these get full type inference:
// 1. Navigation
const navigate = useNavigate()
navigate({ to: '/posts/$postId', params: { postId: '123' } })
// 2. Link component
<Link to="/posts/$postId" params={{ postId: '123' }}>View Post</Link>
// 3. useParams hook
const { postId } = useParams({ from: '/posts/$postId' }) // postId: string
// 4. useSearch hook
const search = useSearch({ from: '/posts' }) // Knows search param types
// 5. useLoaderData hook
const data = useLoaderData({ from: '/posts/$postId' }) // Knows loader return typeFile-Based Routing Setup
// With file-based routing, routeTree is auto-generated
// router.tsx
import { createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen' // Generated file
export const router = createRouter({
routeTree,
defaultPreload: 'intent',
})
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}Context
- Must be done once, typically in your router configuration file
- Enables IDE autocomplete for routes, params, and search params
- Catches invalid routes at compile time
- Works with both file-based and code-based routing
- Required for full TypeScript benefits of TanStack Router
ts-use-from-param: Use from Parameter for Type Narrowing
Priority: CRITICAL
Explanation
When using hooks like useParams, useSearch, or useLoaderData, provide the from parameter to get exact types for that route. Without it, TypeScript returns a union of all possible types across all routes.
Bad Example
// Without 'from' - TypeScript doesn't know which route's types to use
function PostDetail() {
// params could be from ANY route - types are unioned
const params = useParams()
// params: { postId?: string; userId?: string; categoryId?: string; ... }
// TypeScript can't guarantee postId exists
console.log(params.postId) // postId: string | undefined
}
// Similarly for search params
function SearchResults() {
const search = useSearch()
// search: union of ALL routes' search params
}Good Example
// With 'from' - exact types for this specific route
function PostDetail() {
const params = useParams({ from: '/posts/$postId' })
// params: { postId: string } - exactly what this route provides
console.log(params.postId) // postId: string (guaranteed)
}
// Full path matching
function UserPost() {
const params = useParams({ from: '/users/$userId/posts/$postId' })
// params: { userId: string; postId: string }
}
// Search params with type narrowing
function SearchResults() {
const search = useSearch({ from: '/search' })
// search: exactly the validated search params for /search route
}
// Loader data with type inference
function PostPage() {
const { post, comments } = useLoaderData({ from: '/posts/$postId' })
// Exact types from your loader function
}Using Route.fullPath for Type Safety
// routes/posts/$postId.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) => {
const post = await fetchPost(params.postId)
return { post }
},
component: PostComponent,
})
function PostComponent() {
// Use Route.fullPath for guaranteed type matching
const params = useParams({ from: Route.fullPath })
const { post } = useLoaderData({ from: Route.fullPath })
// Or use route-specific helper (preferred in same file)
const { postId } = Route.useParams()
const data = Route.useLoaderData()
}Using getRouteApi for Code-Split Components
// components/PostDetail.tsx (separate file from route)
import { getRouteApi } from '@tanstack/react-router'
// Get type-safe access without importing the route
const postRoute = getRouteApi('/posts/$postId')
export function PostDetail() {
const params = postRoute.useParams()
// params: { postId: string }
const data = postRoute.useLoaderData()
// data: exact loader return type
const search = postRoute.useSearch()
// search: exact search param types
}When to Use strict: false
// In shared components that work across multiple routes
function Breadcrumbs() {
// strict: false returns union types but allows component reuse
const params = useParams({ strict: false })
const location = useLocation()
// params may or may not have certain values
return (
<nav>
{params.userId && <span>User: {params.userId}</span>}
{params.postId && <span>Post: {params.postId}</span>}
</nav>
)
}Context
- Always use
fromin route-specific components for exact types - Use
Route.useParams()/Route.useLoaderData()within route files - Use
getRouteApi()in components split from route files - Use
strict: falseonly in truly generic, cross-route components - The
frompath must match exactly (including params like$postId)
Related skills
How it compares
Use tanstack-router-best-practices for TanStack Router context architecture; use a general React routing guide when the stack is not TanStack Router.
FAQ
What does tanstack-router-best-practices do?
tanstack-router-best-practices teaches type-safe TanStack Router patterns for React routing, loaders, search params, and navigation.
When should I use tanstack-router-best-practices?
Setting up routing, creating routes and layouts, search param handling, loaders, code splitting, or refactoring navigation.
Is this skill safe to install?
Review the Security Audits panel on this page before installing in production.