Tanstack Router: Type-Safe Routing for React
Tanstack Router is the newest member of the Tanstack family, designed from the ground up to bring type‑safe, declarative routing to modern React applications. Unlike traditional routers that rely on string‑based paths, Tanstack Router leverages TypeScript’s type system to catch mismatched routes and missing parameters at compile time. This not only reduces runtime bugs but also gives you autocomplete and IntelliSense throughout your codebase.
In this article we’ll explore the core concepts of Tanstack Router, walk through a full‑featured example, and share pro tips for scaling routing in large React projects. By the end you’ll be able to replace your existing router with a type‑safe alternative that feels native to React and TypeScript.
Getting Started
The first step is installing the package. Tanstack Router works with React 18+ and has peer dependencies on react and react-dom. Run the following command:
npm install @tanstack/router
After installation, create a router.ts file where you’ll define your route tree. The router expects a nested object where each node describes a path, a component, and optionally children, loaders, or route guards.
Defining a Simple Route Tree
Here’s a minimal example that covers a home page, an about page, and a user profile with a dynamic :userId segment.
import { createRouter } from '@tanstack/router';
import Home from './pages/Home';
import About from './pages/About';
import UserProfile from './pages/UserProfile';
export const routeTree = [
{
path: '/',
component: Home,
},
{
path: '/about',
component: About,
},
{
path: '/users/$userId',
component: UserProfile,
// The `$` prefix tells Tanstack Router that this is a param
// and it will be reflected in the generated TypeScript types.
},
] as const;
export const router = createRouter({ routeTree });
Notice the as const assertion. It tells TypeScript to treat the route definitions as literal values, which is essential for generating the type‑safe API.
Hooking the Router into React
Wrap your application with the RouterProvider component. This is similar to BrowserRouter from React Router, but it also injects the generated types.
import React from 'react';
import { RouterProvider } from '@tanstack/router';
import { router } from './router';
function App() {
return (
<RouterProvider router={router}>
<!-- Your layout, navigation, etc. -->
</RouterProvider>
);
}
export default App;
Now every component inside App can access the router via the useRouter hook, which returns a fully typed API.
Navigating with Type Safety
Navigation in Tanstack Router is performed through the router.navigate method or the <Link> component. Because the router knows the exact shape of each route, you get compile‑time validation of path parameters.
Using the Link Component
The Link component accepts a to prop that can be either a string or a typed route reference. The typed version is preferred for safety.
import { Link } from '@tanstack/router';
import { routeTree } from '../router';
function NavBar() {
return (
<nav>
<Link to={routeTree[0]}>Home</Link>
<Link to={routeTree[1]}>About</Link>
<Link
to={routeTree[2]}
params={{ userId: '42' }}
>User 42</Link>
</nav>
);
}
If you accidentally omit userId or provide a string where a number is expected, TypeScript will raise an error before you even run the app.
Programmatic Navigation
Sometimes you need to navigate after an async operation, such as after a form submission. Use the router.navigate function, which also respects the typed route definitions.
import { useRouter } from '@tanstack/router';
import { routeTree } from '../router';
function LoginForm() {
const router = useRouter();
const handleSubmit = async (e) => {
e.preventDefault();
// pretend we logged in successfully
await fakeLogin();
// Navigate to the user's profile using a typed route
router.navigate({
to: routeTree[2],
params: { userId: '123' },
});
};
return (
<form onSubmit={handleSubmit}>
<button type="submit">Log In</button>
</form>
);
}
Because router.navigate receives a strongly typed object, you can’t accidentally pass the wrong param name or type.
Pro tip: Store frequently used routes in a separate routes.ts file and re‑export them. This avoids “magic strings” and keeps your navigation calls consistent across the codebase.
Route Parameters and Type Inference
Dynamic segments are a core feature of any router. Tanstack Router makes them type‑safe by using the $ prefix in the path definition. The generated types expose a Params interface for each route.
Accessing Params in a Component
Inside a component rendered by a route, you can retrieve the parsed parameters via the useParams hook. The hook returns a typed object that matches the route definition.
import { useParams } from '@tanstack/router';
function UserProfile() {
const { userId } = useParams(); // userId is inferred as string
// If you need a number, coerce it safely
const numericId = Number(userId);
if (Number.isNaN(numericId)) {
return <div>Invalid user ID</div>;
}
// Render user details...
return <div>User ID: {numericId}</div>;
}
The type system guarantees that userId exists, so you never need to check for undefined when the route is correctly defined.
Nested Routes with Params
Complex applications often require nested layouts (e.g., a dashboard with sub‑pages). Tanstack Router supports nesting while preserving type safety for each level.
export const routeTree = [
{
path: '/',
component: Layout,
children: [
{
path: 'dashboard',
component: DashboardHome,
},
{
path: 'dashboard/projects/$projectId',
component: ProjectDetail,
},
],
},
] as const;
In ProjectDetail, you can access projectId with full confidence that it matches the route’s definition.
Data Loading with Route Loaders
Fetching data before rendering a route is a common pattern. Tanstack Router introduces loaders, which are async functions attached to a route. Loaders run whenever the route is entered, and their resolved value is injected into the component via the useLoaderData hook.
Creating a Loader
Let’s add a loader to the ProjectDetail route that fetches project data from an API.
import { fetchProject } from '../api/projects';
export const routeTree = [
{
path: '/',
component: Layout,
children: [
{
path: 'dashboard/projects/$projectId',
component: ProjectDetail,
loader: async ({ params }) => {
// params.projectId is typed as string
const project = await fetchProject(params.projectId);
return { project };
},
},
],
},
] as const;
The loader receives a context object that includes params, search, and other useful metadata.
Consuming Loader Data
Inside ProjectDetail you call useLoaderData to get the resolved data. The hook returns a type that matches the loader’s return shape.
import { useLoaderData } from '@tanstack/router';
function ProjectDetail() {
const { project } = useLoaderData(); // project is typed automatically
return (
<section>
<h1>{project.name}</h1>
<p>{project.description}</p>
</section>
);
}
If the loader throws, Tanstack Router will render the nearest ErrorBoundary you provide, giving you a graceful fallback.
Pro tip: Combine loaders withreact-queryortanstack-queryfor caching and background refetching. You can return aqueryKeyfrom the loader and let the component handle the query lifecycle.
Route Guards and Authentication
Many apps need to protect certain routes behind authentication. Tanstack Router offers guards, which are functions that run before a route is entered. If a guard returns false or throws, navigation is aborted and you can redirect the user.
Simple Auth Guard
Assume you have an useAuth hook that exposes the current user. You can create a guard that redirects unauthenticated users to a login page.
import { useAuth } from '../auth';
import { Navigate } from '@tanstack/router';
function authGuard({ router }) {
const { user } = useAuth();
if (!user) {
// Redirect to /login while preserving the intended destination
return router.navigate({
to: '/login',
search: { redirect: router.state.currentLocation.pathname },
});
}
// Return true to allow navigation
return true;
}
// Apply the guard to a protected route
export const routeTree = [
{
path: '/dashboard',
component: Dashboard,
guard: authGuard,
},
{
path: '/login',
component: Login,
},
] as const;
The guard runs synchronously or asynchronously, giving you flexibility to perform token refreshes or async permission checks.
Guard with Async Permission Check
For role‑based access, you might need to fetch the user’s permissions before allowing navigation.
async function roleGuard({ router, params }) {
const { user } = useAuth();
// If we don’t have permissions yet, fetch them
if (!user.permissions) {
await user.loadPermissions(); // async method on the user object
}
const hasAccess = user.permissions.includes('admin');
if (!hasAccess) {
// Show a toast and stay on the current page
toast.error('You do not have admin rights.');
return false;
}
return true;
}
// Admin panel route
{
path: '/admin',
component: AdminPanel,
guard: roleGuard,
}
When the guard returns false, the navigation is cancelled but the URL stays unchanged, preserving a smooth user experience.
Advanced Patterns: Layouts, Parallel Routes, and SSR
Tanstack Router’s architecture mirrors modern UI frameworks like Remix and Next.js, supporting nested layouts, parallel routes (splits), and server‑side rendering out of the box.
Shared Layouts
A layout component can wrap a set of child routes, providing persistent UI such as a sidebar or header. The layout receives the outlet prop, which renders the matched child.
function DashboardLayout({ outlet }) {
return (
<div className="dashboard">
<Sidebar />
<main>{outlet}</main>
</div>
);
}
export const routeTree = [
{
path: '/dashboard',
component: DashboardLayout,
children: [
{ path: '', component: DashboardHome },
{ path: 'settings', component: SettingsPage },
],
},
] as const;
The outlet prop is typed as React.ReactNode, but you can also use the useOutlet hook for more granular control.
Parallel (Split) Routes
Parallel routing lets you render multiple route trees side‑by‑side. This is handy for dashboards where the main content and a side panel have independent navigation states.
export const routeTree = [
{
path: '/',
component: RootLayout,
children: [
{
path: '',
component: Home,
},
{
// Parallel route named "sidebar"
id: 'sidebar',
path: '/sidebar',
component: SidebarRoot,
children: [
{ path: 'chat', component: ChatPanel },
{ path: 'notifications', component: NotificationPanel },
],
},
],
},
] as const;
In RootLayout you render both the main outlet and the parallel outlet using useRouteContext('sidebar').
import { useRouteContext } from '@tanstack/router';
function RootLayout({ outlet }) {
const sidebar = useRouteContext('sidebar');
return (
<div className="app">
<header />
<div className="content">
{outlet}
{sidebar?.outlet}
</div>
</div>
);
}
Server‑Side Rendering (SSR)
Tanstack Router works seamlessly with React’s server rendering APIs. The key is to pre‑load all route loaders on the server and hydrate the client with the same state.
import { renderToString } from 'react-dom/server';
import { RouterProvider, createRouter } from '@tanstack/router';
import { routeTree } from './router';
export async function handleRequest(req) {
const router = createRouter({
routeTree,
// Pass the incoming URL so the router knows the location
context: { location: req.url },
});
// Preload all loaders for the matched route
await router.load();
const html = renderToString(
<RouterProvider router={router}>
<App />
</RouterProvider>
);
// Serialize the router state for hydration
const state = JSON.stringify(router.state);
return `
${html}
`;
}
On the client side, pass the serialized state to RouterProvider so the router starts with the already‑loaded data, eliminating a duplicate network request.
Pro tip: When using SSR, keep loaders pure (no side effects) and avoid directly accessing browser‑only globals. This ensures the same code runs on both server and client without errors.