Web Development · Frontend
TanStack Router in 2026: Type-Safe Routing That Rewires How You Think About Navigation
TanStack Router brings full TypeScript inference to URL params, search params, and loader data. Here's what that looks like in practice and when it's worth adopting.
Anurag Verma
7 min read
Sponsored
React Router has been the default for React SPA routing for years, and it works. But “works” has a ceiling. Navigate to /users/abc123 and params.userId is a string. Pass ?page=2&sort=name as search params and you’re parsing strings by hand. Load data in a route loader and the type of that data is unknown unless you add assertions. At every boundary, you write boilerplate to get from the URL to typed data.
TanStack Router (v1 reached stable in late 2023, and it’s been in steady production use through 2025 and 2026) solves these problems with full TypeScript inference through the routing layer. Params are typed. Search params have schemas with defaults. Loader data is inferred automatically. The type errors come from the router, not from your downstream code.
It’s a different way of thinking about routing, and for TypeScript-first teams, it’s hard to go back once you’ve used it.
The Core Idea: Routes as Type Boundaries
In most routers, routes are runtime constructs. You define them with strings, and TypeScript doesn’t know what params or search params each route accepts. TanStack Router defines routes as typed objects where the type information flows through the rest of the application.
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/users/$userId")({
loader: async ({ params }) => {
// params.userId is typed as string here — inferred from the route path
const user = await fetchUser(params.userId);
return { user };
},
component: UserDetail,
});
function UserDetail() {
const { user } = Route.useLoaderData();
// user is the return type of fetchUser — no type assertion needed
return <div>{user.name}</div>;
}
The route’s param type ($userId), loader data type, and component are all linked. Change the loader’s return type and the component’s useLoaderData() call updates automatically.
File-Based Routing
TanStack Router supports both code-based and file-based routing. The file-based approach is the more ergonomic one for most applications.
Set up the Vite plugin:
// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { TanStackRouterVite } from "@tanstack/router-plugin/vite";
export default defineConfig({
plugins: [TanStackRouterVite(), react()],
});
With the plugin, files in src/routes/ become routes automatically. The plugin watches the files and generates a routeTree.gen.ts file with all the type information. You never write or edit this file manually.
Route file naming:
src/routes/
__root.tsx → / (root layout)
index.tsx → /
users.tsx → /users (layout for /users/*)
users/
index.tsx → /users
$userId.tsx → /users/:userId
$userId.edit.tsx → /users/:userId/edit
settings/
index.tsx → /settings
profile.tsx → /settings/profile
Layouts are expressed with parent route files. users.tsx is the layout for anything under /users/. Nested routes render inside <Outlet /> in the parent.
Search Params with Schemas
URL search params are where the ergonomic difference is most obvious. Most routers give you raw strings. TanStack Router lets you declare a schema with validation and defaults.
import { createFileRoute } from "@tanstack/react-router";
import { z } from "zod";
export const Route = createFileRoute("/users")({
validateSearch: z.object({
page: z.number().int().positive().default(1),
sort: z.enum(["name", "email", "createdAt"]).default("name"),
q: z.string().optional(),
}),
component: UserList,
});
function UserList() {
const { page, sort, q } = Route.useSearch();
// page is number, sort is "name" | "email" | "createdAt", q is string | undefined
// Accessing a key that doesn't exist in the schema is a type error
return (
<div>
<SearchBar defaultValue={q} />
<UserTable sortBy={sort} />
<Pagination current={page} />
</div>
);
}
Updating search params is also typed:
import { useNavigate } from "@tanstack/react-router";
function Pagination({ current }: { current: number }) {
const navigate = useNavigate({ from: "/users" });
return (
<button
onClick={() =>
navigate({
search: (prev) => ({ ...prev, page: current + 1 }),
// TypeScript enforces the shape — you can't pass page: "next"
})
}
>
Next page
</button>
);
}
The search updater function receives the current typed search state and returns a new one. Invalid shapes are type errors at build time.
Loaders and the Data Loading Model
TanStack Router integrates with a data loading model that’s closer to Remix than React Router v6’s optional loaders.
Route loaders run before the component renders and block navigation until they resolve. This means the component always receives fully loaded data, no loading states in the component itself.
// src/routes/projects/$projectId.tsx
import { createFileRoute } from "@tanstack/react-router";
import { queryClient } from "../../query-client";
import { projectQueryOptions } from "../../queries/projects";
export const Route = createFileRoute("/projects/$projectId")({
loader: ({ params }) =>
queryClient.ensureQueryData(projectQueryOptions(params.projectId)),
component: ProjectDetail,
});
function ProjectDetail() {
const project = Route.useLoaderData();
// project is the resolved type from projectQueryOptions — not undefined, not loading
return <h1>{project.name}</h1>;
}
This pairs well with TanStack Query. Use queryClient.ensureQueryData() in the loader to pre-populate the query cache, then useQuery() in the component for subsequent refetches. The first render is always from the pre-loaded cache.
Typed Links
Navigation links are also type-checked. Passing a URL string to <Link> is a type error if the route doesn’t exist:
import { Link } from "@tanstack/react-router";
// Correct — /users/$userId exists
<Link to="/users/$userId" params={{ userId: user.id }}>
{user.name}
</Link>
// Type error — /user/$id is not a registered route
<Link to="/user/$id" params={{ id: user.id }}>
{user.name}
</Link>
// Type error — userId param is required but missing
<Link to="/users/$userId">
{user.name}
</Link>
This catches broken links at compile time. In a codebase where routes change occasionally, this is more valuable than it sounds.
When to Use TanStack Router vs React Router
TanStack Router is a better fit when:
- TypeScript is used consistently throughout the project
- URL state (search params) carries significant application state
- Data loading is coupled to routes and benefits from typed loader data
- You want type-safe links and navigation
React Router is a better fit when:
- The project is JavaScript-only or TypeScript is loosely applied
- The routing is simple: few params, minimal search state, no loaders
- The team is already familiar with React Router and the complexity is manageable
- You need features TanStack Router doesn’t cover yet (e.g., some edge cases in server-side rendering)
Next.js App Router is a separate consideration. For apps built on Next.js, the App Router handles routing, and TanStack Router is redundant. TanStack Router is for SPAs and Vite-based apps primarily, though TanStack Start (a full-stack meta-framework built on TanStack Router) is worth watching for teams who want the router in a server-rendered setup.
Migration from React Router
A full migration can be staged. The TanStack Router docs suggest starting with a wrapper approach:
- Install TanStack Router alongside React Router
- Convert routes one by one to TanStack Router, wrapping the existing React Router component tree temporarily
- Remove React Router once all routes are converted
For smaller apps (under 20 routes), a full migration in a day is realistic. For larger apps, the staged approach avoids a big-bang rewrite.
The most time-consuming part is usually extracting loader data from components (where it was fetched with useEffect) into route loaders. This is also where you see the biggest gains: components shrink substantially when data fetching moves into the router.
TanStack Router won’t make sense for every React project. The setup cost is real, and for simple apps with a handful of routes and no complex URL state, React Router gets you there without the overhead. But for apps where TypeScript precision matters and where URL state drives significant behavior, the typed routing layer changes the experience of working in the codebase. You stop writing defensive code for state that might not exist, because the router guarantees it does.
Sponsored
More from this category
More from Web Development
SolidJS in 2026: Fine-Grained Reactivity Without the Virtual DOM
Three.js and React Three Fiber: 3D on the Web Without the Pain
Web Images in 2026: AVIF, WebP, and the LCP Work Nobody Does Until It's a Problem
Sponsored
Discussion
Join the conversation.
Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.
Sponsored