A sunny view of rustic railway tracks disappearing into the distant mountains. Perfect for travel themes.

TanStack Router vs React Router 8.5: Type-Safe Vite SPAs

· 10 min read

For a client-rendered Vite SPA, TanStack Router gives you better end-to-end type safety. Its search params are validated and typed, and links to them are type-checked too, which React Router 8.5 still leaves to you. React Router 8.5 has closed much of the gap for path params and loader data through its typegen. It's the better pick if you expect to need server rendering, React Server Components, or a router your whole team already knows.

The rest of this piece covers where that difference shows up in code, and what it costs to move from one router to the other.

Where each project stands in October 2026

According to our directory, React Router has 56,621 GitHub stars and its latest release is 8.5.0 (October 19, 2026). TanStack Router has 15,074 stars and its latest release is 1.170.35 (September 10, 2026).

React Router v8 shipped on June 17, 2026. The changelog calls it the first major on a new planned yearly release cadence under the project's Open Governance model. The big changes in v8:

  • Middleware is always on, and the future.v8_middleware flag is gone.
  • Loader, action and middleware context is always a RouterContextProvider.
  • Split route modules are on by default.
  • The react-router-dom re-export package was removed. Framework mode now needs Node 22.22.0+, React 19.2.7+ and Vite 7+.

Five minor releases followed in four months. The 8.5.0 notes include these changes:

  • Opt-in, application-controlled route discovery.
  • A fix for <Await> server rendering with RSC promises.
  • Several RSC streaming fixes.
  • Two unstable framework-mode features: a unstable_fetch prop on <HydratedRouter> and an API-only Framework Mode.

8.5 also deprecates React Router's view transition APIs in favour of React's own <ViewTransition>.

TanStack Router is still on v1. Version numbers in the 1.170s mean a long series of non-breaking releases, so v1 code you wrote a year ago should still compile. As of today it has been about six weeks since its last release. That doesn't show the project is slowing down, but check the GitHub activity yourself if long-term maintenance matters to you.

Type safety: path params, search params and loaders

These three are what you actually type every day, so I'll go through each.

TanStack Router: types come from the route definitions

TanStack Router builds its types from the route tree. With file-based routing, the Vite plugin writes a routeTree.gen.ts file. You register your router once, and from then on every Link, navigate and hook knows every route and its params. The setup has two parts:

1// vite.config.ts
2import { defineConfig } from 'vite'
3import react from '@vitejs/plugin-react'
4import { tanstackRouter } from '@tanstack/router-plugin/vite'
5
6export default defineConfig({
7  plugins: [
8    // must come before the React plugin
9    tanstackRouter({ target: 'react', autoCodeSplitting: true }),
10    react(),
11  ],
12})
1// src/main.tsx
2import { StrictMode } from 'react'
3import { createRoot } from 'react-dom/client'
4import { RouterProvider, createRouter } from '@tanstack/react-router'
5import { routeTree } from './routeTree.gen'
6
7const router = createRouter({ routeTree })
8
9declare module '@tanstack/react-router' {
10  interface Register {
11    router: typeof router
12  }
13}
14
15createRoot(document.getElementById('root')!).render(
16  <StrictMode>
17    <RouterProvider router={router} />
18  </StrictMode>,
19)

Search params are where TanStack pulls ahead. A route declares a validateSearch schema, and the validated result is the type everywhere in your code. That includes the search prop on every Link that points at the route. The loader reads search values through loaderDeps, so the route's cache key includes them:

1// src/routes/projects.$projectId.tsx
2import { createFileRoute, Link } from '@tanstack/react-router'
3import { z } from 'zod'
4
5const issueSearch = z.object({
6  page: z.number().int().min(1).default(1),
7  status: z.enum(['open', 'closed']).default('open'),
8})
9
10type Issue = { id: string; title: string }
11
12export const Route = createFileRoute('/projects/$projectId')({
13  validateSearch: issueSearch,
14  loaderDeps: ({ search }) => ({ page: search.page, status: search.status }),
15  loader: async ({ params, deps }): Promise<Issue[]> => {
16    const res = await fetch(
17      `/api/projects/${params.projectId}/issues?page=${deps.page}&status=${deps.status}`,
18    )
19    if (!res.ok) throw new Error('Failed to load issues')
20    return res.json()
21  },
22  component: ProjectIssues,
23})
24
25function ProjectIssues() {
26  const { projectId } = Route.useParams() // { projectId: string }
27  const { page, status } = Route.useSearch() // { page: number; status: 'open' | 'closed' }
28  const issues = Route.useLoaderData() // Issue[]
29
30  return (
31    <section>
32      <h1>
33        {projectId}: {status} issues, page {page}
34      </h1>
35      <ul>
36        {issues.map((i) => (
37          <li key={i.id}>{i.title}</li>
38        ))}
39      </ul>
40      <Link from={Route.fullPath} search={(prev) => ({ ...prev, page: prev.page + 1 })}>
41        Next page
42      </Link>
43    </section>
44  )
45}

Change status to z.enum(['open', 'closed', 'triaged']) and the compiler flags every Link that passes a value outside that set. Rename $projectId and every link that builds that URL stops compiling. That's what end-to-end means here: the route definition, the URL you build, the values you read and the data you load all share one type.

React Router 8.5: typed params and loader data, untyped search params

React Router's type safety depends on route module typegen, and typegen needs framework mode. That's less of a problem than it sounds for a Vite SPA. Framework mode with ssr: false builds a static SPA that you can deploy to any file host:

1// react-router.config.ts
2import type { Config } from '@react-router/dev/config'
3
4export default { ssr: false } satisfies Config
1// app/routes.ts
2import { type RouteConfig, index, route } from '@react-router/dev/routes'
3
4export default [
5  index('routes/home.tsx'),
6  route('projects/:projectId', 'routes/project-issues.tsx'),
7] satisfies RouteConfig

Advertisement

Each route module then imports types generated for its own path. params and loaderData arrive typed. Search params are a different story: you parse a plain URLSearchParams yourself, and nothing ties the URLs you build to what the loader expects.

1// app/routes/project-issues.tsx
2import { Link, href } from 'react-router'
3import type { Route } from './+types/project-issues'
4
5type Issue = { id: string; title: string }
6
7function parseSearch(sp: URLSearchParams) {
8  const page = Math.max(1, Number(sp.get('page')) || 1)
9  const status: 'open' | 'closed' = sp.get('status') === 'closed' ? 'closed' : 'open'
10  return { page, status }
11}
12
13export async function clientLoader({ params, request }: Route.ClientLoaderArgs) {
14  const { page, status } = parseSearch(new URL(request.url).searchParams)
15  const res = await fetch(
16    `/api/projects/${params.projectId}/issues?page=${page}&status=${status}`,
17  )
18  if (!res.ok) throw new Response('Failed to load issues', { status: res.status })
19  const issues: Issue[] = await res.json()
20  return { issues, page, status }
21}
22
23export default function ProjectIssues({ loaderData, params }: Route.ComponentProps) {
24  const { issues, page, status } = loaderData
25  const base = href('/projects/:projectId', { projectId: params.projectId })
26
27  return (
28    <section>
29      <h1>
30        {params.projectId}: {status} issues, page {page}
31      </h1>
32      <ul>
33        {issues.map((i) => (
34          <li key={i.id}>{i.title}</li>
35        ))}
36      </ul>
37      <Link to={`${base}?page=${page + 1}&status=${status}`}>Next page</Link>
38    </section>
39  )
40}

href() checks the path pattern and its params at compile time, which is a real improvement. The query string after it is still an unchecked string. If you rename status to state in parseSearch, the link keeps compiling and quietly breaks at runtime. You can wrap search params in your own Zod helpers, but then you're maintaining the typing layer that TanStack gives you built in.

Data mode (createBrowserRouter) is lighter, and it's what many existing SPAs run on. It doesn't get typegen, so loader data comes back through generics or casts. In v8, createBrowserRouter is imported from react-router and RouterProvider from react-router/dom.

API ergonomics beyond types

TanStack Router has more built in. Each route can have its own loader cache with stale-while-revalidate behaviour, plus preloading on hover and search-param middleware. The from/to model on Link is very precise. It also takes longer to learn, and you'll read more of the docs before your first non-trivial route. If you already use TanStack Query, the loader-plus-ensureQueryData pattern fits naturally.

React Router's model is easier to explain: there's one loader per route, mutations go through actions, and data revalidates after mutations. v8 adds always-on middleware, which runs as a nested parent-to-child chain in framework and data modes. That makes auth guards and shared request logic simpler to set up. Hiring is easier too: most React developers have used React Router at some point.

Bundle size: measure it in your own build

I couldn't find a current, sourced size comparison for these two versions, so I'm not going to quote a number. Both libraries are designed for code splitting: TanStack with autoCodeSplitting and React Router 8 with split route modules on by default. That means the router runtime matters more than per-route overhead.

The honest way to compare is to build the same three-route app with each router. Run vite build with rollup-plugin-visualizer and look at the gzipped entry chunk. For most SPAs, the gap will be small next to your component library and data layer. Unless you're shipping to very constrained devices, let type safety and ergonomics decide this, not size.

SSR and RSC: where the routers diverge

This matters even for an SPA, because it decides your options later.

React Router's framework mode is the same codebase whether you set ssr: false or ssr: true. Moving a React Router SPA to server rendering is a config change plus loaders that can run on the server. The 8.5 release notes also show ongoing RSC work, such as the <Await> and RSC-streaming fixes. Note that some of that work is still marked unstable.

TanStack Router does SSR through TanStack Start, a separate full-stack framework built on the router. Your routes carry over, but you'd be adopting a new framework and server runtime, not flipping a flag. Check the TanStack Start docs for the current state of its RSC support before you plan around it.

Migrating between them

Moving from React Router to TanStack Router is the more common direction, and TanStack maintains an official migration guide. Here's how the work breaks down:

  1. Rename route files. :param segments become $param.
  2. Move loaders. Loaders move into createFileRoute options, and useLoaderData() becomes Route.useLoaderData().
  3. Rewrite search params. Every useSearchParams call becomes a validateSearch schema plus Route.useSearch(). This is the step that actually takes time, because you have to decide what each search param's type is. It's also where you get the payoff.
  4. Check links. Every <Link to="..."> gets type-checked against the new tree. Expect a burst of compiler errors that point to links that were already broken.
  5. Handle actions. TanStack Router has no actions. Mutations go to TanStack Query or plain handlers that call router.invalidate().

Going the other way means removing schemas and parsing search params by hand again. Few teams choose that unless they're heading toward React Router's server rendering.

If you're on React Router 7 data mode and only want better types, upgrading to v8 framework mode with ssr: false is cheaper than switching libraries. The v7-to-v8 upgrade means enabling the v8 future flags on v7 first. After that, the two unavoidable code changes are moving react-router-dom imports and renaming data to loaderData in meta functions and useMatches().

Which one to pick

A new client-only Vite SPA with filter-, table- or dashboard-heavy URLs. Use TanStack Router. Typed, validated search params prevent a whole class of bugs that React Router can't catch at compile time, and in these apps most of the state lives in the URL.

A new SPA that might need SSR or RSC within a year. Use React Router 8 in framework mode with ssr: false. You get typed params and loader data now, and server rendering later is a config change, not a migration.

An existing React Router SPA that works. Upgrade to v8 and adopt typegen. Migrate to TanStack Router only if search-param bugs are a recurring cost. Switching just for nicer types isn't worth the rewrite.

A team already using TanStack Query. Lean toward TanStack Router. Loaders, caching and invalidation fit together with less glue code.

A large team with mixed experience levels. React Router is the safer default. Its learning curve is gentler and far more developers have already used it.

Advertisement

Share this page

Article by Marcus Rodriguez

Marcus Rodriguez is the ReactLibraries.com byline for Next.js, server components, data fetching and full-stack TypeScript. Articles under this byline are written with AI assistance from current documentation, changelogs and release notes, and are reviewed by the ReactLibraries.com editor before publishing.

Related Content

Continue learning with these related articles