Imported from juanjaragavi/utilities-manager (
.github/skills/tanstack-router/SKILL.md). Install upstream withnpx skills add juanjaragavi/utilities-manager --skill tanstack-router. Copyright stays with the author.
Overview
TanStack Router is a fully type-safe router for React (and Solid) applications. It provides file-based routing, first-class search parameter management, built-in data loading, code splitting, and deep TypeScript integration. It serves as the routing foundation for TanStack Start (the full-stack framework).
Package: @tanstack/react-router
CLI: @tanstack/router-cli or @tanstack/router-plugin (Vite/Rspack/Webpack)
Devtools: @tanstack/react-router-devtools
Installation
npm install @tanstack/react-router
# For file-based routing with Vite:
npm install -D @tanstack/router-plugin
# Or standalone CLI:
npm install -D @tanstack/router-cli
Core Concepts
Route Trees
Routes are organized in a tree structure. The root route is the top-level layout, and child routes nest underneath.
import {
createRootRoute,
createRoute,
createRouter,
} from "@tanstack/react-router";
const rootRoute = createRootRoute({
component: RootLayout,
});
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/",
component: HomePage,
});
const aboutRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/about",
component: AboutPage,
});
const routeTree = rootRoute.addChildren([indexRoute, aboutRoute]);
const router = createRouter({ routeTree });
File-Based Routing
File-based routing automatically generates the route tree from your file structure. Configure with Vite plugin:
// vite.config.ts
import { defineConfig } from "vite";
import { TanStackRouterVite } from "@tanstack/router-plugin/vite";
export default defineConfig({
plugins: [
TanStackRouterVite(),
// ... other plugins
],
});
File Naming Conventions
| File Pattern | Route Type | Example Path |
|---|---|---|
__root.tsx |
Root layout | N/A (wraps all) |
index.tsx |
Index route | / |
about.tsx |
Static route | /about |
$postId.tsx |
Dynamic param | /posts/$postId |
posts.tsx |
Layout route | /posts/* (layout) |
posts/index.tsx |
Nested index | /posts |
posts/$postId.tsx |
Nested dynamic | /posts/123 |
posts_.$postId.tsx |
Pathless layout | /posts/123 (different layout) |
_layout.tsx |
Pathless layout | N/A (groups routes) |
_layout/dashboard.tsx |
Grouped route | /dashboard |
$.tsx |
Splat/catch-all | /* |
posts.$postId.edit.tsx |
Dot notation | /posts/123/edit |
Special Prefixes
_prefix: Pathless routes (layout groups without URL segment)$prefix: Dynamic path parameters(folder)parentheses: Route groups (organizational, no URL impact)
Route Configuration
Each route can define:
// routes/posts.$postId.tsx
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/posts/$postId")({
// Validation for path params
params: {
parse: (params) => ({ postId: Number(params.postId) }),
stringify: (params) => ({ postId: String(params.postId) }),
},
// Search params validation
validateSearch: (search: Record<string, unknown>) => {
return {
page: Number(search.page ?? 1),
filter: (search.filter as string) || "",
};
},
// Data loading
loader: async ({ params, context, abortController }) => {
return fetchPost(params.postId);
},
// Loader dependencies (re-run loader when these change)
loaderDeps: ({ search }) => ({ page: search.page }),
// Stale time for cached loader data
staleTime: 5_000,
// Preloading
preloadStaleTime: 30_000,
// Error component
errorComponent: PostErrorComponent,
// Pending/loading component
pendingComponent: PostLoadingComponent,
// 404 component
notFoundComponent: PostNotFoundComponent,
// Before load hook (authentication, redirects)
beforeLoad: async ({ context, location }) => {
if (!context.auth.isAuthenticated) {
throw redirect({
to: "/login",
search: { redirect: location.href },
});
}
},
// Head/meta management
head: () => ({
meta: [{ title: "Post Details" }],
}),
// Component
component: PostComponent,
});
function PostComponent() {
const { postId } = Route.useParams();
const post = Route.useLoaderData();
const { page, filter } = Route.useSearch();
return <div>{post.title}</div>;
}
Data Loading
Route Loaders
export const Route = createFileRoute("/posts")({
loader: async ({ context }) => {
// Access router context (e.g., queryClient)
const posts = await context.queryClient.ensureQueryData({
queryKey: ["posts"],
queryFn: fetchPosts,
});
return { posts };
},
component: PostsComponent,
});
function PostsComponent() {
const { posts } = Route.useLoaderData();
// ...
}
Loader Dependencies
Control when loaders re-execute:
export const Route = createFileRoute("/posts")({
loaderDeps: ({ search: { page, filter } }) => ({ page, filter }),
loader: async ({ deps: { page, filter } }) => {
return fetchPosts({ page, filter });
},
});
Deferred Data Loading
Stream non-critical data:
import { Await, defer } from "@tanstack/react-router";
export const Route = createFileRoute("/dashboard")({
loader: async () => {
const criticalData = await fetchCriticalData();
const deferredData = defer(fetchSlowData());
return { criticalData, deferredData };
},
component: DashboardComponent,
});
function DashboardComponent() {
const { criticalData, deferredData } = Route.useLoaderData();
return (
<div>
<CriticalSection data={criticalData} />
<Suspense fallback={<Loading />}>
<Await promise={deferredData}>
{(data) => <SlowSection data={data} />}
</Await>
</Suspense>
</div>
);
}
Context-Based Data Loading
Provide shared dependencies via router context:
// Create router with context
const router = createRouter({
routeTree,
context: {
queryClient,
auth: undefined!, // Will be provided by RouterProvider
},
});
// In root/app component
function App() {
const auth = useAuth();
return <RouterProvider router={router} context={{ auth }} />;
}
// In routes
export const Route = createFileRoute("/protected")({
beforeLoad: ({ context }) => {
if (!context.auth.user) throw redirect({ to: "/login" });
},
loader: ({ context }) => {
return context.queryClient.ensureQueryData(userQueryOptions());
},
});
Search Parameters
Validation
import { z } from "zod";
const postSearchSchema = z.object({
page: z.number().default(1),
filter: z.string().default(""),
sort: z.enum(["date", "title"]).default("date"),
});
export const Route = createFileRoute("/posts")({
validateSearch: postSearchSchema,
// Or manual validation:
// validateSearch: (search) => postSearchSchema.parse(search),
});
Reading Search Params
function PostsComponent() {
// From route
const { page, filter, sort } = Route.useSearch();
// Or from any component with useSearch hook
const search = useSearch({ from: "/posts" });
}
Updating Search Params
import { useNavigate } from "@tanstack/react-router";
function Pagination() {
const navigate = useNavigate();
const { page } = Route.useSearch();
return (
<button
onClick={() =>
navigate({
search: (prev) => ({ ...prev, page: prev.page + 1 }),
})
}
>
Next Page
</button>
);
}
// Or via Link component
<Link to="/posts" search={(prev) => ({ ...prev, page: 2 })}>
Page 2
</Link>;
Search Param Options
const router = createRouter({
routeTree,
// Custom serialization
search: {
strict: true, // Reject unknown params
},
// Default search param serializer
stringifySearch: defaultStringifySearch,
parseSearch: defaultParseSearch,
});
Navigation
Link Component
import { Link } from '@tanstack/react-router'
// Static route
<Link to="/about">About</Link>
// Dynamic route with params
<Link to="/posts/$postId" params={{ postId: '123' }}>
Post 123
</Link>
// With search params
<Link to="/posts" search={{ page: 2, filter: 'react' }}>
Page 2
</Link>
// Active link styling
<Link
to="/posts"
activeProps={{ className: 'active' }}
inactiveProps={{ className: 'inactive' }}
activeOptions={{ exact: true }}
>
Posts
</Link>
// Preloading
<Link to="/posts" preload="intent">Posts</Link>
<Link to="/dashboard" preload="viewport">Dashboard</Link>
// Hash
<Link to="/docs" hash="api-reference">API Reference</Link>
Programmatic Navigation
import { useNavigate, useRouter } from "@tanstack/react-router";
function MyComponent() {
const navigate = useNavigate();
const router = useRouter();
// Navigate to a route
navigate({ to: "/posts", search: { page: 1 } });
// Navigate with replace
navigate({ to: "/posts", replace: true });
// Relative navigation
navigate({ to: ".", search: (prev) => ({ ...prev, page: 2 }) });
// Go back/forward
router.history.back();
router.history.forward();
// Invalidate and reload current route
router.invalidate();
}
Redirects
import { redirect } from "@tanstack/react-router";
// In beforeLoad or loader
throw redirect({
to: "/login",
search: { redirect: location.href },
// Optional status code
statusCode: 301, // Permanent redirect (SSR)
});
Navigation Blocking
import { useBlocker } from "@tanstack/react-router";
function FormComponent() {
const [isDirty, setIsDirty] = useState(false);
useBlocker({
shouldBlockFn: () => isDirty,
withResolver: true, // Shows confirm dialog
});
// Or with custom UI
const { proceed, reset, status } = useBlocker({
shouldBlockFn: () => isDirty,
});
if (status === "blocked") {
return (
<div>
<p>Are you sure you want to leave?</p>
<button onClick={proceed}>Leave</button>
<button onClick={reset}>Stay</button>
</div>
);
}
}
Code Splitting
Automatic (File-Based Routing)
With file-based routing, create a lazy file:
routes/
posts.tsx # Critical: loader, beforeLoad, meta
posts.lazy.tsx # Lazy: component, pendingComponent, errorComponent
// posts.tsx (loaded eagerly)
export const Route = createFileRoute("/posts")({
loader: () => fetchPosts(),
});
// posts.lazy.tsx (loaded lazily)
import { createLazyFileRoute } from "@tanstack/react-router";
export const Route = createLazyFileRoute("/posts")({
component: PostsComponent,
pendingComponent: PostsLoading,
errorComponent: PostsError,
});
Manual Code Splitting
const postsRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/posts",
loader: () => fetchPosts(),
}).lazy(() => import("./posts.lazy").then((d) => d.Route));
Preloading
// Router-level defaults
const router = createRouter({
routeTree,
defaultPreload: 'intent', // 'intent' | 'viewport' | 'render' | false
defaultPreloadStaleTime: 30_000, // 30 seconds
})
// Route-level
export const Route = createFileRoute('/posts/$postId')({
// Stale time for the loader data
staleTime: 5_000,
// How long preloaded data stays fresh
preloadStaleTime: 30_000,
})
// Link-level
<Link to="/posts" preload="intent" preloadDelay={100}>
Posts
</Link>
Type Safety
Register Router Type
// Declare module for type inference
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
Type-Safe Hooks
All hooks are fully typed based on the route tree:
// useParams - typed to route's params
const { postId } = useParams({ from: "/posts/$postId" });
// useSearch - typed to route's search schema
const { page } = useSearch({ from: "/posts" });
// useLoaderData - typed to loader return
const data = useLoaderData({ from: "/posts/$postId" });
// useRouteContext - typed to route context
const { auth } = useRouteContext({ from: "/protected" });
Route Generics
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/posts/$postId")({
// TypeScript infers:
// params: { postId: string }
// search: validated search schema type
// loaderData: return type of loader
// context: router context type
});
Authenticated Routes
// __root.tsx
export const Route = createRootRouteWithContext<{
auth: AuthContext;
}>()({
component: RootComponent,
});
// _authenticated.tsx (pathless layout for auth)
export const Route = createFileRoute("/_authenticated")({
beforeLoad: ({ context, location }) => {
if (!context.auth.isAuthenticated) {
throw redirect({
to: "/login",
search: { redirect: location.href },
});
}
},
});
// _authenticated/dashboard.tsx
export const Route = createFileRoute("/_authenticated/dashboard")({
component: Dashboard, // Only accessible when authenticated
});
Scroll Restoration
const router = createRouter({
routeTree,
// Enable scroll restoration
defaultScrollRestoration: true,
})
// Or per-route
export const Route = createFileRoute('/posts')({
// Scroll to top on navigation
scrollRestoration: true,
})
// Custom scroll restoration key
<ScrollRestoration
getKey={(location) => location.pathname}
/>
Route Masking
Display a different URL than the actual route:
<Link
to="/photos/$photoId"
params={{ photoId: photo.id }}
mask={{ to: "/photos", search: { photoId: photo.id } }}
>
View Photo
</Link>;
// Or programmatically
navigate({
to: "/photos/$photoId",
params: { photoId: photo.id },
mask: { to: "/photos", search: { photoId: photo.id } },
});
Not Found Handling
// Global 404
const router = createRouter({
routeTree,
defaultNotFoundComponent: () => <div>Page not found</div>,
});
// Route-level 404
export const Route = createFileRoute("/posts/$postId")({
loader: async ({ params }) => {
const post = await fetchPost(params.postId);
if (!post) throw notFound();
return post;
},
notFoundComponent: () => <div>Post not found</div>,
});
Head Management
export const Route = createFileRoute("/posts/$postId")({
head: ({ loaderData }) => ({
meta: [
{ title: loaderData.title },
{ name: "description", content: loaderData.excerpt },
{ property: "og:title", content: loaderData.title },
],
links: [
{ rel: "canonical", href: `https://example.com/posts/${loaderData.id}` },
],
}),
});
Integration with TanStack Query
import { queryOptions } from "@tanstack/react-query";
const postsQueryOptions = queryOptions({
queryKey: ["posts"],
queryFn: fetchPosts,
});
export const Route = createFileRoute("/posts")({
loader: ({ context: { queryClient } }) => {
// Ensure data is in cache, won't refetch if fresh
return queryClient.ensureQueryData(postsQueryOptions);
},
component: PostsComponent,
});
function PostsComponent() {
// Use the same query options for reactive updates
const { data: posts } = useSuspenseQuery(postsQueryOptions);
return <PostsList posts={posts} />;
}
Router Hooks Reference
| Hook | Purpose |
|---|---|
useRouter() |
Access router instance |
useRouterState() |
Subscribe to router state |
useParams() |
Get route path params |
useSearch() |
Get validated search params |
useLoaderData() |
Get route loader data |
useRouteContext() |
Get route context |
useNavigate() |
Get navigate function |
useLocation() |
Get current location |
useMatches() |
Get all matched routes |
useMatch() |
Get specific route match |
useBlocker() |
Block navigation |
useLinkProps() |
Get link props for custom components |
useMatchRoute() |
Check if a route matches |
Best Practices
- Use file-based routing for most applications - it's simpler and auto-generates the route tree
- Validate search params with Zod or custom validators for type safety
- Use
loaderDepsto control when loaders re-execute based on search param changes - Leverage context for dependency injection (QueryClient, auth state)
- Use
beforeLoadfor authentication guards, not in components - Separate critical vs lazy code - keep loaders in the main file, components in
.lazy.tsx - Use
preload="intent"on Links for perceived performance - Use
staleTimeto prevent unnecessary refetches during navigation - Register the router type for full TypeScript inference across the app
- Use
notFound()instead of conditional rendering for 404 states - Colocate search param logic with routes that own them
- Use pathless layouts (
_authenticated) for shared auth/layout logic without URL segments
Common Pitfalls
- Forgetting to register the router type (
declare module) - Not using
loaderDepswhen loader depends on search params (causes stale data) - Putting auth checks in components instead of
beforeLoad(flash of protected content) - Not handling the loading state with
pendingComponent - Using
useEffectfor data fetching instead of route loaders - Mutating search params directly instead of using navigate/Link
- Not wrapping the app with
RouterProvider - Forgetting
getParentRoutein code-based route definitions