TanStack Router Patterns
Purpose
File-based routing with TanStack Router, emphasizing type-safe navigation, route loaders, and lazy loading.
When to Use This Skill
-
Creating new routes
-
Implementing navigation
-
Using route loaders for data
-
Type-safe routing with parameters
-
Lazy loading routes
Quick Start
Basic Route
// routes/posts/index.tsx import { createFileRoute } from '@tanstack/react-router'; import { postsApi } from '~/features/posts/api/postsApi';
export const Route = createFileRoute('/posts')({ loader: async () => { const posts = await postsApi.getAll(); return { posts }; }, component: PostsPage, });
function PostsPage() { const { posts } = Route.useLoaderData();
return ( <div> <h1>Posts</h1> {posts.map(post => ( <PostCard key={post.id} post={post} /> ))} </div> ); }
File-Based Routing
Directory Structure
routes/ ├── __root.tsx # Root route ├── index.tsx # / ├── about.tsx # /about ├── posts/ │ ├── index.tsx # /posts │ └── $postId.tsx # /posts/:postId └── users/ ├── index.tsx # /users └── $userId/ ├── index.tsx # /users/:userId └── posts.tsx # /users/:userId/posts
Route Mapping
File Path → URL Path routes/index.tsx → / routes/about.tsx → /about routes/posts/index.tsx → /posts routes/posts/$postId.tsx → /posts/:postId routes/users/$userId/index.tsx → /users/:userId routes/users/$userId/posts.tsx → /users/:userId/posts
Route Parameters
Dynamic Routes
// routes/posts/$postId.tsx import { createFileRoute } from '@tanstack/react-router'; import { postsApi } from '~/features/posts/api/postsApi';
export const Route = createFileRoute('/posts/$postId')({ loader: async ({ params }) => { const post = await postsApi.get(params.postId); return { post }; }, component: PostDetails, });
function PostDetails() { const { post } = Route.useLoaderData(); const { postId } = Route.useParams();
return ( <div> <h1>{post.title}</h1> <p>{post.content}</p> </div> ); }
Multiple Parameters
// routes/users/$userId/posts/$postId.tsx export const Route = createFileRoute('/users/$userId/posts/$postId')({ loader: async ({ params }) => { const { userId, postId } = params; const post = await postsApi.getByUserAndId(userId, postId); return { post }; }, component: UserPostDetails, });
Route Loaders
Basic Loader
export const Route = createFileRoute('/posts')({ loader: async () => { const posts = await postsApi.getAll(); return { posts }; }, component: PostsPage, });
Loader with Dependencies
export const Route = createFileRoute('/users/$userId/posts')({ loader: async ({ params, context }) => { const [user, posts] = await Promise.all([ userApi.get(params.userId), postsApi.getByUser(params.userId), ]); return { user, posts }; }, component: UserPosts, });
Loader Error Handling
export const Route = createFileRoute('/posts/$postId')({ loader: async ({ params }) => { try { const post = await postsApi.get(params.postId); return { post, error: null }; } catch (error) { return { post: null, error: 'Post not found' }; } }, component: PostDetails, });
function PostDetails() { const { post, error } = Route.useLoaderData();
if (error) return <Error message={error} />; return <div>{post.title}</div>; }
Navigation
import { Link, useNavigate } from '@tanstack/react-router';
// Link component <Link to="/posts/$postId" params={{ postId: '123' }}>View Post</Link>
// Programmatic navigation const navigate = useNavigate(); navigate({ to: '/posts', search: { filter: 'published' } });
Lazy Loading
Lazy Route Component
// routes/posts/index.tsx import { createFileRoute } from '@tanstack/react-router'; import { lazy } from 'react';
const PostsPage = lazy(() => import('~/features/posts/PostsPage'));
export const Route = createFileRoute('/posts')({ component: PostsPage, });
Lazy Loader
export const Route = createFileRoute('/posts')({ loader: async () => { // Dynamically import heavy module only when route loads const { processData } = await import('~/lib/heavyModule'); const posts = await postsApi.getAll(); const processed = processData(posts); return { posts: processed }; }, component: PostsPage, });
Search Params
Type-Safe Search Params
import { z } from 'zod';
const postsSearchSchema = z.object({ filter: z.enum(['all', 'published', 'draft']).default('all'), sort: z.enum(['date', 'title']).default('date'), page: z.number().default(1), });
export const Route = createFileRoute('/posts')({ validateSearch: postsSearchSchema, loader: async ({ search }) => { const posts = await postsApi.getAll(search); return { posts }; }, component: PostsPage, });
function PostsPage() { const { posts } = Route.useLoaderData(); const search = Route.useSearch();
return ( <div> <p>Filter: {search.filter}</p> <p>Sort: {search.sort}</p> <p>Page: {search.page}</p> </div> ); }
Updating Search Params
import { useNavigate } from '@tanstack/react-router';
function FilterButtons() { const navigate = useNavigate(); const search = Route.useSearch();
const setFilter = (filter: string) => { navigate({ to: '.', search: (prev) => ({ ...prev, filter }), }); };
return ( <div> <button onClick={() => setFilter('all')}>All</button> <button onClick={() => setFilter('published')}>Published</button> <button onClick={() => setFilter('draft')}>Draft</button> </div> ); }
Layouts
Root Layout
// routes/__root.tsx import { createRootRoute, Outlet } from '@tanstack/react-router';
export const Route = createRootRoute({ component: RootLayout, });
function RootLayout() { return ( <div> <Header /> <main> <Outlet /> {/* Child routes render here */} </main> <Footer /> </div> ); }
Nested Layouts
// routes/dashboard.tsx export const Route = createFileRoute('/dashboard')({ component: DashboardLayout, });
function DashboardLayout() { return ( <div className="dashboard"> <Sidebar /> <div className="content"> <Outlet /> {/* Dashboard child routes */} </div> </div> ); }
// routes/dashboard/index.tsx export const Route = createFileRoute('/dashboard')({ component: DashboardHome, });
// routes/dashboard/analytics.tsx export const Route = createFileRoute('/dashboard/analytics')({ component: Analytics, });
Route Guards
Authentication Guard
export const Route = createFileRoute('/admin')({ beforeLoad: async ({ context }) => { if (!context.auth.isAuthenticated) { throw redirect({ to: '/login', search: { redirect: '/admin' }, }); } }, component: AdminPage, });
Permission Guard
export const Route = createFileRoute('/admin/users')({ beforeLoad: async ({ context }) => { if (!context.auth.hasPermission('users:manage')) { throw redirect({ to: '/unauthorized' }); } }, component: UsersPage, });
Breadcrumbs
Route Breadcrumbs
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) => {
const post = await postsApi.get(params.postId);
return { post };
},
meta: ({ loaderData }) => [
{ title: 'Home', path: '/' },
{ title: 'Posts', path: '/posts' },
{ title: loaderData.post.title, path: /posts/${loaderData.post.id} },
],
component: PostDetails,
});
Best Practices
- Use Loaders for Data
// ✅ Good: Loader fetches data export const Route = createFileRoute('/posts')({ loader: async () => { const posts = await postsApi.getAll(); return { posts }; }, component: PostsPage, });
// ❌ Avoid: Fetching in component function PostsPage() { const [posts, setPosts] = useState([]);
useEffect(() => { postsApi.getAll().then(setPosts); }, []);
return <div>...</div>; }
- Lazy Load Heavy Routes
// ✅ Good: Lazy load admin panel const AdminPanel = lazy(() => import('~/features/admin/AdminPanel'));
export const Route = createFileRoute('/admin')({ component: AdminPanel, });
- Type-Safe Navigation
// ✅ Good: Type-safe Link <Link to="/posts/$postId" params={{ postId: post.id }}> View Post </Link>
// ❌ Avoid: String concatenation
<a href={/posts/${post.id}}>View Post</a>
Additional Resources
For more patterns, see:
-
routing-guide.md - Advanced routing
-
navigation-patterns.md - Navigation strategies
-
route-loaders.md - Complex loaders