
Tanstack Router
- 49 installs
- 101 repo stars
- Updated November 28, 2025
- blencorp/claude-code-kit
tanstack-router is a Claude Code skill that documents file-based routing patterns for TanStack Router in React, including loaders, type-safe navigation, and search params.
About
This skill teaches file-based routing with TanStack Router in React. It covers creating routes, dynamic and multi-parameter routes, route loaders for data fetching, type-safe navigation with Link and useNavigate, and type-safe search params validated with zod. A developer uses it when adding or restructuring routes in a TanStack Router app.
- File-based routing patterns for TanStack Router in React
- Type-safe navigation, route loaders, and search-param validation with zod
- Lazy loading of route components and heavy modules
Tanstack Router by the numbers
- 49 all-time installs (skills.sh)
- Ranked #1,313 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
tanstack-router capabilities & compatibility
- Capabilities
- frontend
- Use cases
- frontend
What tanstack-router says it does
File-based routing with TanStack Router, emphasizing type-safe navigation, route loaders, and lazy loading.
TanStack Router file-based routing patterns including route creation, navigation, loaders, type-safe routing, and lazy loading.
npx skills add https://github.com/blencorp/claude-code-kit --skill tanstack-routerAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 49 |
|---|---|
| repo stars | ★ 101 |
| Last updated | November 28, 2025 |
| Repository | blencorp/claude-code-kit ↗ |
What it does
Create type-safe file-based routes, loaders, and navigation in a TanStack Router React app.
Who is it for?
Developers building or refactoring routes in a React app that uses TanStack Router.
Skip if: Projects using Next.js App Router, React Router, or non-React frameworks.
When should I use this skill?
When creating routes, implementing navigation, or working with TanStack Router loaders and search params.
What you get
Working file-based routes with loaders, type-safe navigation, and validated search params.
- File-based route definitions
- Route loaders
- Type-safe navigation and search params
Files
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/postsRoute 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
1. 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>;
}2. Lazy Load Heavy Routes
// ✅ Good: Lazy load admin panel
const AdminPanel = lazy(() => import('~/features/admin/AdminPanel'));
export const Route = createFileRoute('/admin')({
component: AdminPanel,
});3. 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
Navigation Patterns for TanStack Router
Link Component
Basic Link Usage
import { Link } from '@tanstack/react-router';
// Simple link
<Link to="/about">About Us</Link>
// Link with params
<Link
to="/posts/$postId"
params={{ postId: '123' }}
>
View Post
</Link>
// Link with search params
<Link
to="/posts"
search={{ filter: 'published', sort: 'date', page: 1 }}
>
Published Posts
</Link>
// Link with hash
<Link to="/docs" hash="installation">
Installation Docs
</Link>Active Link Styling
// Using activeProps
<Link
to="/dashboard"
activeProps={{
className: 'active-link',
style: { fontWeight: 'bold', color: 'blue' }
}}
>
Dashboard
</Link>
// Using activeOptions
<Link
to="/posts"
activeOptions={{
exact: true, // Only active on exact match
includeSearch: false // Ignore search params
}}
activeProps={{
className: 'active'
}}
>
Posts
</Link>
// Custom active check
<Link
to="/posts"
activeProps={(isActive) => ({
className: isActive ? 'bg-blue-500' : 'bg-gray-200'
})}
>
Posts
</Link>Inactive Link Styling
<Link
to="/archive"
inactiveProps={{
className: 'text-gray-400',
style: { opacity: 0.6 }
}}
>
Archive
</Link>Preloading on Hover
<Link
to="/posts/$postId"
params={{ postId: '123' }}
preload="intent" // Preload on hover/focus
>
View Post
</Link>
// Options: false | 'intent' | 'viewport' | 'render'
// - false: No preloading
// - intent: Preload on hover/focus
// - viewport: Preload when in viewport
// - render: Preload immediately on renderDisabled Links
<Link
to="/premium"
disabled={!isPremiumUser}
activeProps={{
className: isPremiumUser ? 'active' : 'disabled'
}}
>
Premium Features
</Link>Programmatic Navigation
useNavigate Hook
import { useNavigate } from '@tanstack/react-router';
function MyComponent() {
const navigate = useNavigate();
const handleSubmit = () => {
// Simple navigation
navigate({ to: '/success' });
};
const handleEdit = (postId: string) => {
// Navigate with params
navigate({
to: '/posts/$postId/edit',
params: { postId }
});
};
const handleFilter = () => {
// Navigate with search params
navigate({
to: '/posts',
search: { filter: 'published', page: 1 }
});
};
const handleBack = () => {
// Navigate back
navigate({ to: '..' });
};
return <button onClick={handleSubmit}>Submit</button>;
}Navigation with State
function CreatePost() {
const navigate = useNavigate();
const handleCreate = async () => {
const post = await createPost(formData);
navigate({
to: '/posts/$postId',
params: { postId: post.id },
state: {
successMessage: 'Post created successfully!'
}
});
};
}
// In destination component
function PostDetails() {
const location = useLocation();
const message = location.state?.successMessage;
return (
<div>
{message && <Alert>{message}</Alert>}
{/* ... */}
</div>
);
}Replace vs Push
// Push to history (default)
navigate({ to: '/posts' });
// Replace current history entry
navigate({ to: '/posts', replace: true });
// Useful for redirects after form submission
const handleLogin = async () => {
await login(credentials);
navigate({ to: '/dashboard', replace: true });
};Relative Navigation
// From /posts/123
navigate({ to: '..' }); // Goes to /posts
navigate({ to: '../..' }); // Goes to /
navigate({ to: './edit' }); // Goes to /posts/123/edit
navigate({ to: 'comments' }); // Goes to /posts/123/commentsRouter Hook
useRouter
import { useRouter } from '@tanstack/react-router';
function Component() {
const router = useRouter();
// Navigate
router.navigate({ to: '/posts' });
// Get current route
const currentRoute = router.state.location.pathname;
// Invalidate route
router.invalidate();
// Preload route
router.preloadRoute({
to: '/posts/$postId',
params: { postId: '123' }
});
// Match route
const match = router.matchRoute({ to: '/posts/$postId' });
return <div>{currentRoute}</div>;
}Route Matching
import { useMatches, useMatchRoute } from '@tanstack/react-router';
function Breadcrumbs() {
const matches = useMatches();
return (
<div>
{matches.map((match) => (
<span key={match.id}>
{match.context.breadcrumb} /
</span>
))}
</div>
);
}
function NavItem({ to }: { to: string }) {
const matchRoute = useMatchRoute();
const isActive = matchRoute({ to, fuzzy: true });
return (
<Link
to={to}
className={isActive ? 'active' : ''}
>
Item
</Link>
);
}Search Param Management
Updating Search Params
import { useNavigate, useSearch } from '@tanstack/react-router';
function FilteredList() {
const navigate = useNavigate();
const search = useSearch({ from: '/posts' });
const updateFilter = (filter: string) => {
navigate({
search: (prev) => ({
...prev,
filter,
page: 1 // Reset page when filter changes
})
});
};
const updatePage = (page: number) => {
navigate({
search: (prev) => ({ ...prev, page })
});
};
return (
<div>
<select
value={search.filter}
onChange={(e) => updateFilter(e.target.value)}
>
<option value="all">All</option>
<option value="published">Published</option>
</select>
<button onClick={() => updatePage(search.page + 1)}>
Next Page
</button>
</div>
);
}Search Param Validation
import { z } from 'zod';
const searchSchema = z.object({
filter: z.enum(['all', 'published', 'draft']).default('all'),
page: z.number().int().positive().default(1),
sort: z.enum(['date', 'title']).default('date')
});
export const Route = createFileRoute('/posts')({
validateSearch: searchSchema,
component: PostsList
});
function PostsList() {
const search = useSearch({ from: '/posts' });
// search is type-safe and validated
// search.filter is 'all' | 'published' | 'draft'
// search.page is number
// search.sort is 'date' | 'title'
}Navigation Guards
Confirmation Before Navigation
function UnsavedForm() {
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const navigate = useNavigate();
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (hasUnsavedChanges) {
e.preventDefault();
e.returnValue = '';
}
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}, [hasUnsavedChanges]);
const handleNavigateAway = (to: string) => {
if (hasUnsavedChanges) {
const confirmed = window.confirm('You have unsaved changes. Leave anyway?');
if (!confirmed) return;
}
navigate({ to });
};
return <Form onChange={() => setHasUnsavedChanges(true)} />;
}Authenticated Navigation
export const Route = createFileRoute('/_authenticated')({
beforeLoad: ({ context, location }) => {
if (!context.auth.isAuthenticated) {
throw redirect({
to: '/login',
search: {
redirect: location.href // Return here after login
}
});
}
}
});
// Login component
function Login() {
const navigate = useNavigate();
const search = useSearch({ from: '/login' });
const handleLogin = async () => {
await loginUser();
const redirectTo = search.redirect || '/dashboard';
navigate({ to: redirectTo });
};
}Advanced Navigation Patterns
Multi-Step Forms
function MultiStepForm() {
const navigate = useNavigate();
const search = useSearch({ from: '/signup' });
const step = search.step || 1;
const nextStep = () => {
navigate({
search: (prev) => ({ ...prev, step: step + 1 })
});
};
const previousStep = () => {
navigate({
search: (prev) => ({ ...prev, step: step - 1 })
});
};
return (
<div>
{step === 1 && <Step1 onNext={nextStep} />}
{step === 2 && <Step2 onNext={nextStep} onBack={previousStep} />}
{step === 3 && <Step3 onBack={previousStep} />}
</div>
);
}Modal Navigation
function Posts() {
const navigate = useNavigate();
const search = useSearch({ from: '/posts' });
const openModal = (postId: string) => {
navigate({
search: (prev) => ({ ...prev, modal: postId })
});
};
const closeModal = () => {
navigate({
search: (prev) => {
const { modal, ...rest } = prev;
return rest;
}
});
};
return (
<div>
<PostList onPostClick={openModal} />
{search.modal && (
<Modal onClose={closeModal}>
<PostDetails postId={search.modal} />
</Modal>
)}
</div>
);
}Optimistic Navigation
function PostActions({ postId }: { postId: string }) {
const navigate = useNavigate();
const router = useRouter();
const handleDelete = async () => {
// Navigate immediately (optimistic)
navigate({ to: '/posts' });
try {
await deletePost(postId);
} catch (error) {
// Revert navigation on error
navigate({ to: '/posts/$postId', params: { postId } });
router.invalidate();
}
};
return <button onClick={handleDelete}>Delete</button>;
}Best Practices
1. Use Type-Safe Links
Always use the Link component with proper typing for params and search.
2. Preload Intentionally
Use preload="intent" for frequently accessed routes.
3. Validate Search Params
Use Zod schemas to validate and type search parameters.
4. Handle Navigation Errors
Always handle potential navigation errors (auth redirects, not found, etc.).
5. Use Relative Navigation
Prefer relative navigation (to: '..') over absolute paths when appropriate.
6. Centralize Route Definitions
Define route paths in a central location for easier refactoring.
// routes.ts
export const ROUTES = {
posts: {
list: '/posts',
detail: (id: string) => `/posts/${id}`,
edit: (id: string) => `/posts/${id}/edit`
}
} as const;
// Usage
<Link to={ROUTES.posts.detail('123')}>View Post</Link>Route Loaders - Complex Data Loading Patterns
Basic Loader Patterns
Simple Data Fetching
export const Route = createFileRoute('/posts')({
loader: async () => {
const posts = await fetchPosts();
return { posts };
},
component: PostsList
});
function PostsList() {
const { posts } = Route.useLoaderData();
return (
<div>
{posts.map(post => (
<PostCard key={post.id} post={post} />
))}
</div>
);
}Loader with Parameters
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) => {
const post = await fetchPost(params.postId);
return { post };
},
component: PostDetails
});Loader with Search Params
export const Route = createFileRoute('/posts')({
validateSearch: z.object({
filter: z.enum(['all', 'published']).default('all'),
page: z.number().default(1)
}),
loader: async ({ search }) => {
const posts = await fetchPosts({
filter: search.filter,
page: search.page
});
return { posts };
},
component: PostsList
});Advanced Loader Patterns
Parallel Data Loading
export const Route = createFileRoute('/dashboard')({
loader: async ({ context }) => {
// Load multiple resources in parallel
const [user, stats, recentPosts, notifications] = await Promise.all([
fetchUser(context.userId),
fetchStats(),
fetchRecentPosts(),
fetchNotifications()
]);
return { user, stats, recentPosts, notifications };
},
component: Dashboard
});Dependent Data Loading
export const Route = createFileRoute('/users/$userId/posts')({
loader: async ({ params }) => {
// First, fetch the user
const user = await fetchUser(params.userId);
// Then fetch posts for that user (depends on user existing)
const posts = await fetchUserPosts(user.id);
// Then fetch the user's settings (depends on user)
const settings = await fetchUserSettings(user.id);
return { user, posts, settings };
},
component: UserPosts
});Conditional Loading
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params, context }) => {
const post = await fetchPost(params.postId);
// Only fetch comments if user is authenticated
const comments = context.auth.isAuthenticated
? await fetchComments(params.postId)
: [];
// Only fetch edit history if user can edit
const history = context.auth.canEdit(post)
? await fetchEditHistory(params.postId)
: null;
return { post, comments, history };
},
component: PostDetails
});Error Handling in Loaders
Throwing Errors
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) => {
const post = await fetchPost(params.postId);
if (!post) {
throw new NotFoundError(`Post ${params.postId} not found`);
}
if (post.status === 'draft' && !context.auth.isAdmin) {
throw new ForbiddenError('You cannot view draft posts');
}
return { post };
},
errorComponent: ({ error }) => {
if (error instanceof NotFoundError) {
return <NotFound message={error.message} />;
}
if (error instanceof ForbiddenError) {
return <Forbidden message={error.message} />;
}
return <ErrorFallback error={error} />;
},
component: PostDetails
});Try-Catch in Loaders
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) => {
try {
const post = await fetchPost(params.postId);
return { post, error: null };
} catch (error) {
console.error('Failed to load post:', error);
return {
post: null,
error: error.message || 'Failed to load post'
};
}
},
component: PostDetails
});
function PostDetails() {
const { post, error } = Route.useLoaderData();
if (error) {
return <ErrorMessage message={error} />;
}
return <div>{post.title}</div>;
}Loader Context
Accessing Router Context
// Define context type in root
interface RouterContext {
auth: AuthService;
queryClient: QueryClient;
supabase: SupabaseClient;
}
export const Route = createRootRoute<RouterContext>({
component: () => <Outlet />,
});
// Use context in loader
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params, context }) => {
const { data: post } = await context.supabase
.from('posts')
.select('*')
.eq('id', params.postId)
.single();
return { post };
},
component: PostDetails
});Providing Custom Context
export const Route = createFileRoute('/posts')({
beforeLoad: ({ context }) => {
return {
...context,
postService: new PostService(context.supabase)
};
},
loader: async ({ context }) => {
// context.postService is now available
const posts = await context.postService.getAll();
return { posts };
},
component: PostsList
});Integration with TanStack Query
Using Query Client in Loaders
import { queryClient } from './queryClient';
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params, context }) => {
// Use TanStack Query for caching
const post = await context.queryClient.fetchQuery({
queryKey: ['post', params.postId],
queryFn: () => fetchPost(params.postId),
staleTime: 5 * 60 * 1000 // 5 minutes
});
return { post };
},
component: PostDetails
});Prefetching Related Data
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params, context }) => {
const { queryClient } = context;
// Fetch main data
const post = await queryClient.fetchQuery({
queryKey: ['post', params.postId],
queryFn: () => fetchPost(params.postId)
});
// Prefetch related data (don't await)
queryClient.prefetchQuery({
queryKey: ['comments', params.postId],
queryFn: () => fetchComments(params.postId)
});
queryClient.prefetchQuery({
queryKey: ['author', post.authorId],
queryFn: () => fetchAuthor(post.authorId)
});
return { post };
},
component: PostDetails
});Loader Optimization
Caching Loader Results
const postCache = new Map<string, Post>();
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) => {
// Check cache first
if (postCache.has(params.postId)) {
return { post: postCache.get(params.postId)! };
}
const post = await fetchPost(params.postId);
postCache.set(params.postId, post);
return { post };
},
component: PostDetails
});Deduplicating Requests
const pendingRequests = new Map<string, Promise<Post>>();
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) => {
// If already fetching this post, return the same promise
if (pendingRequests.has(params.postId)) {
const post = await pendingRequests.get(params.postId)!;
return { post };
}
// Create new request
const promise = fetchPost(params.postId);
pendingRequests.set(params.postId, promise);
try {
const post = await promise;
return { post };
} finally {
pendingRequests.delete(params.postId);
}
},
component: PostDetails
});Stale-While-Revalidate Pattern
export const Route = createFileRoute('/posts')({
loader: async ({ context }) => {
return await context.queryClient.fetchQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
staleTime: 5 * 60 * 1000, // Consider fresh for 5 mins
gcTime: 10 * 60 * 1000, // Keep in cache for 10 mins
});
},
component: PostsList
});Loader Redirects
Conditional Redirects
export const Route = createFileRoute('/admin')({
beforeLoad: ({ context }) => {
if (!context.auth.isAdmin) {
throw redirect({ to: '/' });
}
},
loader: async () => {
const adminData = await fetchAdminData();
return { adminData };
},
component: AdminDashboard
});Redirect After Data Load
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) => {
const post = await fetchPost(params.postId);
// Redirect if post is deleted
if (post.status === 'deleted') {
throw redirect({ to: '/posts' });
}
// Redirect to canonical URL if slug doesn't match
if (post.slug !== params.postId) {
throw redirect({
to: '/posts/$postId',
params: { postId: post.slug }
});
}
return { post };
},
component: PostDetails
});Loader Abort Signals
Canceling Requests
export const Route = createFileRoute('/posts')({
loader: async ({ abortController }) => {
// Pass abort signal to fetch
const posts = await fetch('/api/posts', {
signal: abortController.signal
}).then(res => res.json());
return { posts };
},
component: PostsList
});Cleanup on Navigation
export const Route = createFileRoute('/long-running-task')({
loader: async ({ abortController }) => {
const task = startLongRunningTask();
// Clean up if user navigates away
abortController.signal.addEventListener('abort', () => {
task.cancel();
});
const result = await task.wait();
return { result };
},
component: TaskResults
});Best Practices
1. Keep Loaders Pure
Loaders should be pure functions without side effects (except data fetching).
// ✅ Good
loader: async ({ params }) => {
const data = await fetchData(params.id);
return { data };
}
// ❌ Bad (has side effects)
loader: async ({ params }) => {
const data = await fetchData(params.id);
updateGlobalState(data); // Side effect!
return { data };
}2. Handle Loading and Error States
export const Route = createFileRoute('/posts')({
loader: async () => {
const posts = await fetchPosts();
return { posts };
},
pendingComponent: () => <LoadingSpinner />,
errorComponent: ({ error }) => <ErrorMessage error={error} />,
component: PostsList
});3. Use Context for Shared Services
Don't create new service instances in loaders; use context:
// ✅ Good
loader: async ({ context }) => {
return await context.postService.getAll();
}
// ❌ Bad
loader: async () => {
const service = new PostService(); // Creating instance in loader
return await service.getAll();
}4. Optimize with Parallel Loading
// ✅ Good (parallel)
const [user, posts] = await Promise.all([
fetchUser(id),
fetchPosts(id)
]);
// ❌ Bad (sequential)
const user = await fetchUser(id);
const posts = await fetchPosts(id);5. Type Loader Return Values
interface PostLoaderData {
post: Post;
comments: Comment[];
}
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }): Promise<PostLoaderData> => {
const [post, comments] = await Promise.all([
fetchPost(params.postId),
fetchComments(params.postId)
]);
return { post, comments };
},
component: PostDetails
});Advanced Routing Guide for TanStack Router
File-Based Routing Patterns
Route File Conventions
routes/
├── __root.tsx # Root layout
├── index.tsx # /
├── about.tsx # /about
├── posts/
│ ├── index.tsx # /posts
│ ├── $postId.tsx # /posts/:postId
│ └── $postId/
│ ├── edit.tsx # /posts/:postId/edit
│ └── comments.tsx # /posts/:postId/comments
├── users/
│ ├── $userId.tsx # /users/:userId
│ └── $userId.settings.tsx # /users/:userId/settings
└── _layout/ # Layout route (no path)
├── dashboard.tsx # /dashboard (uses _layout)
└── settings.tsx # /settings (uses _layout)Dynamic Route Parameters
// 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: PostDetails
});
function PostDetails() {
const { post } = Route.useLoaderData();
return <div>{post.title}</div>;
}Catch-All Routes
// routes/docs/$.tsx - Matches /docs/*
export const Route = createFileRoute('/docs/$')({
component: DocsPage
});
function DocsPage() {
const { _splat } = Route.useParams();
// _splat contains the entire remaining path
// e.g., for /docs/guide/getting-started, _splat = "guide/getting-started"
return <DocContent path={_splat} />;
}Optional Parameters
// routes/search/$term?.tsx - $term is optional
export const Route = createFileRoute('/search/$term')({
component: SearchPage
});
function SearchPage() {
const { term } = Route.useParams();
// term might be undefined
return <SearchResults query={term || ''} />;
}Route Nesting and Layouts
Pathless Layout Routes
// routes/_layout.tsx - Layout with no path
export const Route = createFileRoute('/_layout')({
component: AuthLayout
});
function AuthLayout() {
const { user } = useAuth();
if (!user) return <Navigate to="/login" />;
return (
<div>
<Sidebar />
<Outlet /> {/* Child routes render here */}
</div>
);
}
// routes/_layout/dashboard.tsx - Uses _layout, path is /dashboard
export const Route = createFileRoute('/_layout/dashboard')({
component: Dashboard
});Nested Layouts
// routes/_app.tsx
export const Route = createFileRoute('/_app')({
component: AppLayout
});
function AppLayout() {
return (
<div>
<Header />
<Outlet />
<Footer />
</div>
);
}
// routes/_app/_authenticated.tsx - Nested under _app
export const Route = createFileRoute('/_app/_authenticated')({
beforeLoad: ({ context }) => {
if (!context.auth.isAuthenticated) {
throw redirect({ to: '/login' });
}
},
component: () => <Outlet />
});
// routes/_app/_authenticated/profile.tsx - Path is /profile
export const Route = createFileRoute('/_app/_authenticated/profile')({
component: ProfilePage
});Route Context
Providing Context
// routes/__root.tsx
import { createRootRoute, Outlet } from '@tanstack/react-router';
interface RouterContext {
auth: AuthService;
queryClient: QueryClient;
}
export const Route = createRootRoute<RouterContext>({
component: () => <Outlet />,
context: () => ({
auth: authService,
queryClient
})
});Consuming Context
// routes/dashboard.tsx
export const Route = createFileRoute('/dashboard')({
beforeLoad: ({ context }) => {
// Access auth from context
if (!context.auth.isAuthenticated) {
throw redirect({ to: '/login' });
}
},
loader: async ({ context }) => {
// Use queryClient from context
const data = await context.queryClient.fetchQuery({
queryKey: ['dashboard'],
queryFn: fetchDashboardData
});
return { data };
},
component: DashboardPage
});Route Matching
Route Rank
Routes are matched in this order: 1. Static routes (exact match) 2. Dynamic routes (with params) 3. Catch-all routes
// Higher priority
/posts/new // Static
/posts/$postId // Dynamic
/posts/* // Catch-all (lowest priority)Route Preloading
import { useRouter } from '@tanstack/react-router';
function PostLink({ postId }: { postId: string }) {
const router = useRouter();
const handleMouseEnter = () => {
// Preload route data on hover
router.preloadRoute({
to: '/posts/$postId',
params: { postId }
});
};
return (
<Link
to="/posts/$postId"
params={{ postId }}
onMouseEnter={handleMouseEnter}
>
View Post
</Link>
);
}Route Guards and Redirects
Before Load Hook
export const Route = createFileRoute('/admin/users')({
beforeLoad: async ({ context, location }) => {
const { auth } = context;
// Check authentication
if (!auth.isAuthenticated) {
throw redirect({
to: '/login',
search: {
redirect: location.href
}
});
}
// Check permissions
if (!auth.hasPermission('admin')) {
throw redirect({ to: '/forbidden' });
}
},
component: AdminUsersPage
});Conditional Redirects
export const Route = createFileRoute('/posts/$postId/edit')({
beforeLoad: async ({ params, context }) => {
const post = await fetchPost(params.postId);
const canEdit = await context.auth.canEdit(post);
if (!canEdit) {
throw redirect({
to: '/posts/$postId',
params: { postId: params.postId }
});
}
return { post };
},
component: EditPost
});Error Handling
Route Error Boundaries
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) => {
const post = await fetchPost(params.postId);
if (!post) {
throw new NotFoundError('Post not found');
}
return { post };
},
errorComponent: ({ error }) => {
if (error instanceof NotFoundError) {
return <NotFound message={error.message} />;
}
return <ErrorFallback error={error} />;
},
component: PostDetails
});Pending Component
export const Route = createFileRoute('/posts')({
loader: async () => {
const posts = await fetchPosts();
return { posts };
},
pendingComponent: () => <LoadingSpinner />,
component: PostsList
});Advanced Patterns
Route Meta Tags
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) => {
const post = await fetchPost(params.postId);
return { post };
},
meta: ({ loaderData }) => [
{ title: loaderData.post.title },
{ name: 'description', content: loaderData.post.excerpt },
{ property: 'og:title', content: loaderData.post.title },
{ property: 'og:image', content: loaderData.post.image }
],
component: PostDetails
});Route Validation
import { z } from 'zod';
const PostSearchSchema = z.object({
filter: z.enum(['all', 'published', 'draft']).default('all'),
sort: z.enum(['date', 'title', 'views']).default('date'),
page: z.number().int().positive().default(1)
});
export const Route = createFileRoute('/posts')({
validateSearch: (search) => PostSearchSchema.parse(search),
loader: async ({ search }) => {
// search is now type-safe and validated
const posts = await fetchPosts(search);
return { posts };
},
component: PostsList
});Parallel Data Loading
export const Route = createFileRoute('/dashboard')({
loader: async ({ context }) => {
// Load all data in parallel
const [stats, recentPosts, notifications, user] = await Promise.all([
fetchStats(),
fetchRecentPosts(),
fetchNotifications(),
fetchUser(context.auth.userId)
]);
return { stats, recentPosts, notifications, user };
},
component: Dashboard
});Best Practices
1. Use File-Based Routing
Prefer file-based routing over manual route configuration for better organization and type safety.
2. Colocate Route Data Loading
Keep loaders close to the components that use them for better maintainability.
3. Handle Loading and Error States
Always provide pendingComponent and errorComponent for better UX.
4. Validate Search Params
Use Zod or similar for runtime validation of search parameters.
5. Preload Critical Routes
Preload routes on hover or mount for faster navigation.
6. Use Layouts Effectively
Leverage pathless layouts (_layout) to avoid prop drilling and centralize auth logic.
{
"tanstack-router": {
"type": "domain",
"enforcement": "suggest",
"priority": "high",
"promptTriggers": {
"keywords": [
"tanstack router",
"@tanstack/react-router",
"createFileRoute",
"createRootRoute",
"createRoute",
"createRouter",
"RouterProvider",
"useNavigate",
"useParams",
"useSearch",
"useLoaderData",
"useRouter",
"useMatches",
"useMatch",
"useLocation",
"route loader",
"Route.lazy",
"beforeLoad",
"validateSearch",
"Link",
"Outlet",
"Navigate",
"redirect",
"notFound"
],
"intentPatterns": [
"create.*tanstack.*route",
"add.*tanstack.*route",
"setup.*tanstack.*router",
"implement.*file.*based.*routing",
"create.*route.*loader",
"add.*route.*validation",
"use.*tanstack.*navigation",
"create.*lazy.*route",
"implement.*route.*guard",
"add.*search.*params.*validation",
"create.*router.*tanstack"
]
},
"fileTriggers": {
"pathPatterns": [
"**/routes/**/*.tsx",
"**/routes/**/*.ts",
"**/routes/**/*.lazy.tsx",
"**/routes/**/*.lazy.ts",
"**/__root.tsx"
],
"contentPatterns": [
"createFileRoute\\(",
"createRootRoute\\(",
"createRoute\\(",
"createRouter\\(",
"import.*@tanstack/react-router",
"from '@tanstack/react-router'",
"RouterProvider",
"useNavigate\\(\\)",
"useParams\\(\\)",
"useSearch\\(\\)",
"Route\\.lazy"
]
}
}
}
Related skills
FAQ
How do you fetch data for a TanStack Router route?
Add an async loader to createFileRoute and read it in the component with Route.useLoaderData().
How do you make search params type-safe in TanStack Router?
Pass a zod schema to validateSearch on the route, then read them with Route.useSearch().