TanStack Router vs React Router v7: Type-Safe Routing in 2026
TL;DR
TanStack Router wins on type safety — search params, path params, and loader data are all fully typed with zero configuration. React Router v7 (which merged with Remix) wins on ecosystem size, Server Components support, and the broader Next.js/Remix world. For a standalone React SPA, TanStack Router's type safety is genuinely superior. For a full-stack app with server rendering, React Router v7's loader pattern and Next.js's file-based routing are better integrated.
Key Takeaways
- TanStack Router: 1.2M weekly downloads (↑ fast), type-safe search params/loaders, devtools, ~40KB
- React Router v7: 12M+ weekly downloads, merged with Remix, now supports RSC/server mode
- Type safety: TanStack Router is superior — no casting, no
params as { id: string } - File-based routing: both support it — TanStack via
@tanstack/router-plugin, React Router via conventions - Data loading: TanStack's
loaderDatais typed; React Router v7'sloaderrequires manual typing - For SPAs: TanStack Router is the modern choice
- For SSR/full-stack: React Router v7 + Remix or Next.js
Download Comparison
| Package | Weekly Downloads | Trend |
|---|---|---|
react-router | ~12M | → Stable (Remix merger) |
@tanstack/react-router | ~1.2M | ↑ +120% YoY |
@remix-run/react | ~2.8M | → (merging into react-router) |
TanStack Router is growing extremely fast from a smaller base.
TanStack Router: Full Type Safety
File-Based Routing Setup
// npm install @tanstack/react-router @tanstack/router-plugin
// routes/__root.tsx — root route
import { createRootRoute, Outlet } from '@tanstack/react-router';
import { TanStackRouterDevtools } from '@tanstack/router-devtools';
export const Route = createRootRoute({
component: () => (
<>
<nav>...</nav>
<Outlet />
<TanStackRouterDevtools />
</>
),
});
// routes/posts/$postId.tsx — dynamic route
import { createFileRoute } from '@tanstack/react-router';
import { fetchPost } from '../api';
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) => {
// params.postId is string — fully typed from filename
return fetchPost(params.postId);
},
component: PostPage,
});
function PostPage() {
// loaderData is typed to return type of fetchPost:
const post = Route.useLoaderData(); // Post — no casting!
const { postId } = Route.useParams(); // string — no casting!
return <h1>{post.title}</h1>;
}
Type-Safe Search Params (TanStack's Killer Feature)
// routes/products/index.tsx
import { createFileRoute } from '@tanstack/react-router';
import { z } from 'zod';
const searchSchema = z.object({
page: z.number().catch(1),
category: z.enum(['all', 'electronics', 'clothing']).catch('all'),
q: z.string().optional(),
sort: z.enum(['price', 'name', 'date']).optional(),
});
export const Route = createFileRoute('/products/')({
validateSearch: searchSchema,
component: ProductsPage,
});
function ProductsPage() {
// All search params are typed — no URL parsing, no casting:
const { page, category, q, sort } = Route.useSearch();
// ^^^^ ^^^^^^^^ ^ ^^^^
// number 'all'|'electronics'|'clothing' string|undefined etc.
const navigate = useNavigate({ from: Route.fullPath });
return (
<div>
<input
value={q ?? ''}
onChange={(e) => navigate({ search: (prev) => ({ ...prev, q: e.target.value, page: 1 }) })}
/>
{/* Pagination: */}
<button onClick={() => navigate({ search: (prev) => ({ ...prev, page: prev.page - 1 }) })}>
Previous
</button>
</div>
);
}
Compare this to React Router — you'd use useSearchParams() and manually parse/cast everything.
React Router v7: The Remix Merger
React Router v7 unifies Remix's loader/action patterns with client-side routing:
Full-Stack Mode (Framework Mode)
// app/routes/products.$productId.tsx — like Remix
import type { Route } from './+types/products.$productId';
import { db } from '~/lib/db';
// Typed loader (requires codegen from Remix/RR7 compiler):
export async function loader({ params }: Route.LoaderArgs) {
const product = await db.product.findUnique({ where: { id: params.productId } });
if (!product) throw new Response('Not Found', { status: 404 });
return { product };
}
export default function ProductPage({ loaderData }: Route.ComponentProps) {
const { product } = loaderData; // Typed via codegen
return <h1>{product.name}</h1>;
}
// Server Actions (forms → mutations):
export async function action({ request, params }: Route.ActionArgs) {
const formData = await request.formData();
const name = formData.get('name') as string;
await db.product.update({ where: { id: params.productId }, data: { name } });
return redirect('/products');
}
Client-Only Mode (SPA)
// React Router v7 in SPA mode (no SSR):
import { createBrowserRouter, RouterProvider } from 'react-router';
const router = createBrowserRouter([
{
path: '/products/:productId',
loader: async ({ params }) => {
return fetch(`/api/products/${params.productId}`).then(r => r.json());
},
Component: ProductPage,
},
]);
function ProductPage() {
const data = useLoaderData() as Product; // Manual cast required
const { productId } = useParams(); // string | undefined
return <h1>{data.name}</h1>;
}
Side-by-Side
| TanStack Router | React Router v7 | |
|---|---|---|
| Downloads | 1.2M/week | 12M+/week |
| Type safety | End-to-end automatic | Codegen or manual |
| Search params typing | ✅ Zod schemas | ❌ Manual |
| SSR/Server Components | Partial | ✅ (Framework mode) |
| File-based routing | ✅ Plugin | ✅ Convention |
| Devtools | ✅ | ✅ |
| Bundle size | ~40KB | ~32KB |
| Ecosystem | Growing | Vast |
Recommendation
Choose TanStack Router if:
→ Building a React SPA (client-side only)
→ Type safety for search params is important
→ Complex query parameters with validation
→ Don't need SSR or Server Components
Choose React Router v7 if:
→ Full-stack app with server rendering
→ Using Remix architecture
→ Need vast ecosystem compatibility
→ Team is already familiar with React Router
Compare TanStack Router and React Router download trends on PkgPulse.
See the live comparison
View tanstack router vs. react router on PkgPulse →