Chapter 05
🔁 Chapter 5 · Routing in 2026 — Two Flavours
Picking between React Router v7 and TanStack Router in 2026 — file-based vs config-based, code-splitting at the route boundary, and auth-guarded routes.
Published 2026-05-23
🎯 Chapter Goal — After this chapter you can pick between React Router v7 and TanStack Router with concrete reasons, structure routes with code-splitting and loaders that don’t break under Suspense, and ship an end-to-end authentication flow — login, route guards, redirect-after-login, logout — that works the same shape under either router.
🧭 Prerequisites — Comfort with hooks and Suspense (Ch 2, Ch 3). For the auth section, a quick read of Ch 2 §2.2 (Actions /
useActionState/useFormStatus) helps. Sample app:examples/ch05-routing/auth-flow/.
🔹 5.1 React Router v7 — the established default
React Router v7 is what most production React SPAs use in 2026. It’s the merger of the old declarative React Router and the loader-based Remix router, with one consistent API. The 19.x release line added refs-as-props and Suspense-aware loaders; the v7 release line consolidated the framework-flavored and SPA-flavored APIs.
You get two modes:
- Declarative mode — what teams migrating from older React Router are familiar with. Routes declared via JSX
<Route>elements; navigation via<Link>anduseNavigate. Loaders are optional. - Data router mode (the modern default) — routes declared as a config tree; each route can declare a
loader, anaction, andComponent. Navigation triggers loaders before rendering. Errors funnel into per-route error elements.
This chapter — and the example — uses data router mode. It’s the one with the better Suspense story, the cleaner auth integration, and the path forward to RR’s framework mode (cross-link Ch 23 for Next-style frameworks built on top of it).
The minimum viable setup
📄 examples/ch05-routing/auth-flow/src/main.tsx
import { createBrowserRouter, RouterProvider } from 'react-router';
const router = createBrowserRouter([
{
path: '/',
Component: App,
children: [
{ index: true, Component: Home },
{ path: 'login', Component: Login },
{ path: 'dashboard', Component: Dashboard },
],
},
]);
createRoot(document.getElementById('root')!).render(
<RouterProvider router={router} />,
);
Three things to notice:
Component:rather thanelement: <Component />. WithComponent, RR handles the JSX construction internally — and it composes withReact.lazyso you can drop a code-split component straight in. The olderelement:syntax still works.children:is real route nesting, not just URL nesting. Anything in the children array renders inside the parent’s<Outlet />. This is how layout routes work.- No
BrowserRouter.RouterProvideris the provider; you wrap it once at the root.
Loaders, actions, and <Form>
A loader runs before the route renders. It can fetch data, check auth, throw a redirect. An action runs on a form submit. Both are async functions, both can throw redirect() to abort and navigate elsewhere.
const dashboardLoader = async () => {
const res = await fetch('/api/me');
if (res.status === 401) throw redirect('/login'); // ← loader-level guard
return res.json();
};
const router = createBrowserRouter([
{
path: 'dashboard',
Component: Dashboard,
loader: dashboardLoader,
},
]);
Inside Dashboard, read the loader data with useLoaderData() — synchronously, because the router has already awaited the loader before mounting the component.
<Form> (from react-router) submits to the matching route’s action without a page reload. It composes with React 19.2 useActionState if you want field-level pending state. For most cases the router’s own useNavigation() hook is enough.
useNavigation() — the “is this route transitioning?” hook
import { useNavigation } from 'react-router';
const Header = () => {
const navigation = useNavigation();
const busy = navigation.state !== 'idle';
return <div aria-busy={busy}>{busy && <ProgressBar />}{/* … */}</div>;
};
navigation.state is 'idle', 'loading' (a loader is running), or 'submitting' (an action is running). This is the right hook for app-level loading indicators. Per-route Suspense boundaries handle granular fallbacks.
🔹 5.2 TanStack Router — the type-safe challenger
TanStack Router (https://tanstack.com/router) is what you reach for when type safety on routes is the priority. The route tree generates a RegisteredRouter type; every Link, useParams, useSearch, and useNavigate checks paths and params at compile time.
Three things it does that React Router doesn’t, out of the box:
- End-to-end typed routes.
<Link to="/invoices/$id" params={{ id }} />— the path is checked against the route tree; the params shape is inferred from the path. Typos are compile errors, not 404s. - First-class search params. Search params are treated as typed state (not as a string parser headache). You declare a schema; the router validates and parses on every navigation.
- File-based routing as a first-class option with full type-safety. A
routes/folder becomes a typed route tree at build time.
The trade-offs: smaller community, smaller ecosystem of recipes, steeper initial setup if you go file-based.
Minimum viable setup (code-based)
import { createRouter, createRoute, createRootRoute, RouterProvider, Outlet } from '@tanstack/react-router';
const rootRoute = createRootRoute({
component: () => (
<>
<Header />
<Outlet />
</>
),
});
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: Home,
});
const dashboardRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/dashboard',
component: Dashboard,
});
const routeTree = rootRoute.addChildren([indexRoute, dashboardRoute]);
const router = createRouter({ routeTree });
declare module '@tanstack/react-router' {
interface Register { router: typeof router }
}
createRoot(document.getElementById('root')!).render(<RouterProvider router={router} />);
The declare module block is what makes the typing global. After it, every Link, useParams, useNavigate in your app is typed against this router instance.
Search params as state
import { z } from 'zod';
const invoicesRoute = createRoute({
path: '/invoices',
validateSearch: z.object({
status: z.enum(['all', 'paid', 'pending', 'overdue']).default('all'),
page: z.number().int().min(1).default(1),
}),
component: InvoicesPage,
});
// In InvoicesPage:
const { status, page } = invoicesRoute.useSearch();
// types: status: 'all' | 'paid' | 'pending' | 'overdue'; page: number
This is the headline feature. Filters in URLs are typed end-to-end. A bookmarked URL with a bad search param fails validation (loud) rather than silently rendering wrong data.
Cross-router parity for auth (preview)
The auth pattern in §5.5 ports cleanly between routers — the useAuth() hook is identical, only the route-tree wiring differs. RR v7 uses a layout route component (<RequireAuth>); TanStack Router uses a route’s beforeLoad to check auth and throw redirect(…).
🔹 5.3 File-based vs config-based — trade-offs
| Concern | Config-based | File-based |
|---|---|---|
| Discoverability | Need to read router.tsx to find a route | ls routes/ reveals the URL space |
| Refactor: rename a URL | Edit the route config | git mv a file |
| Refactor: split out a feature | Move the route block | git mv a folder |
| Toolchain | Pure TS, no codegen | Build step generates a route tree at compile time |
| Layout nesting | Explicit children: array | Implicit by folder structure |
| Search-param schemas | Inline next to the route | Same — colocated in the file |
| Best for | Small/medium apps; teams that prefer explicit config | Large apps; teams that scan folders for navigation |
My recommendation: start config-based. Move to file-based when you have 30+ routes and route discovery becomes painful.
Both React Router v7 and TanStack Router support file-based. RR v7’s file-based mode is called “framework mode” and is closer to Next-style; TanStack Router’s file-based mode preserves the pure-SPA shape and just generates the route tree.
🔹 5.4 Code-splitting at the route boundary
Routes are the right place to code-split. Most users hit two or three routes in a session — there’s no reason to ship the other twenty up front.
React Router v7
import { lazy } from 'react';
const Dashboard = lazy(() => import('@/routes/Dashboard'));
const Profile = lazy(() => import('@/routes/Profile'));
const router = createBrowserRouter([
{
path: '/',
Component: App,
children: [
{ path: 'dashboard', Component: Dashboard },
{ path: 'profile', Component: Profile },
],
},
]);
Component: accepts a React.lazy result directly. No <Suspense> boundary is needed at the route level — RR v7 has a built-in one. If you want a custom fallback, wrap your <App /> in a <Suspense fallback={…}> and your <Outlet /> will render the fallback while the chunk loads.
A more advanced pattern uses lazy: directly in the route definition — it lets RR fetch the chunk and the route’s loader in parallel:
const router = createBrowserRouter([
{
path: '/dashboard',
lazy: () => import('@/routes/Dashboard.route'),
// Dashboard.route.ts exports { Component, loader, action } in one module
},
]);
TanStack Router
const dashboardRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/dashboard',
component: lazyRouteComponent(() => import('@/routes/Dashboard'), 'Dashboard'),
});
lazyRouteComponent is TanStack’s equivalent — takes a dynamic import and an optional named export.
Mock — what the network panel shows
First load (/)
index.[hash].js 18 kB
react-vendor.[hash].js 42 kB
router.[hash].js 11 kB
App+Home.[hash].js 6 kB ◀── shell + landing only
Click Dashboard
Dashboard.[hash].js 7 kB ◀── lazy chunk fetched on click
⚠️ Don’t lazy-load the route the user is currently on. If /dashboard is your most-visited route, including it in the initial bundle saves the click → fetch → render round-trip. Measure before you split.
🔹 5.5 Auth-guarded routes — the book’s primary authentication example
The runnable scaffold is at examples/ch05-routing/auth-flow/. It’s a four-route React Router v7 app with login, logout, two protected routes, and a layout-route guard. The shape of the code transfers cleanly to TanStack Router; we’ll show that variant inline.
The architecture
┌─ AuthProvider ────────────────────────────────────────────────┐
│ state: { status: 'anonymous' } | { status: 'auth', user } │
│ login(email, password): POST /api/login → user │
│ logout(): POST /api/logout │
└───────────────────────────────────────────────────────────────┘
▲ ▲ ▲
│ │ │
<Login> form <RequireAuth> everyone else
calls login() wraps protected reads via useAuth()
on submit routes via <Outlet>
AuthProvider owns the state. Every component reads via useAuth(). The router doesn’t know about auth directly — RequireAuth is the bridge.
The useAuth() hook and provider
📄 examples/ch05-routing/auth-flow/src/auth/AuthProvider.tsx
import { createContext, use, useState, type ReactNode } from 'react';
import { fakeLogin, fakeLogout, type User } from './api';
type AuthState =
| { status: 'anonymous' }
| { status: 'authenticated'; user: User };
type AuthValue = {
state: AuthState;
login: (email: string, password: string) => Promise<User>;
logout: () => Promise<void>;
};
const AuthContext = createContext<AuthValue | null>(null);
export const AuthProvider = ({ children }: { children: ReactNode }) => {
const [state, setState] = useState<AuthState>({ status: 'anonymous' });
const login = async (email: string, password: string) => {
const user = await fakeLogin(email, password);
setState({ status: 'authenticated', user });
return user;
};
const logout = async () => {
await fakeLogout();
setState({ status: 'anonymous' });
};
return <AuthContext value={{ state, login, logout }}>{children}</AuthContext>;
};
export const useAuth = () => {
const value = use(AuthContext); // ← React 19.2 use(Context)
if (!value) throw new Error('useAuth must be used inside <AuthProvider>');
return value;
};
The shape decisions worth pausing on
stateis a discriminated union, not auser | null.state.status === 'authenticated'narrows TypeScript sostate.useris non-nullable inside the guard. No null-checks scattered across consumers.useAuth()usesuse(Context)(Ch 2 §2.3). It throws if called outside the provider — a common cause of “why isstateundefined” bugs becomes a loud error.- The
Promise<User>return onloginlets the caller (<Login>) navigate after success without awaiting twice.
The route-guard component
📄 examples/ch05-routing/auth-flow/src/auth/RequireAuth.tsx
import { Navigate, Outlet, useLocation } from 'react-router';
import { useAuth } from './AuthProvider';
export const RequireAuth = () => {
const { state } = useAuth();
const location = useLocation();
if (state.status === 'anonymous') {
return <Navigate to="/login" replace state={{ from: location.pathname }} />;
}
return <Outlet />;
};
Two things here matter:
state={{ from: location.pathname }}— preserves the attempted destination so<Login>can bounce the user back after success. Thereplaceflag means the unauthenticated URL doesn’t sit in the back button history.<Outlet />— when authenticated, render the nested protected routes. This is the layout-route pattern: a route with no path of its own, used purely to wrap children with a behavior.
Wiring it into the router
📄 examples/ch05-routing/auth-flow/src/main.tsx
const router = createBrowserRouter([
{
path: '/',
Component: App,
children: [
{ index: true, Component: Home },
{ path: 'login', Component: Login },
{
Component: RequireAuth, // ← layout route with no path
children: [
{ path: 'dashboard', Component: Dashboard },
{ path: 'profile', Component: Profile },
],
},
],
},
]);
Anything inside the RequireAuth branch goes through the guard. Adding a new protected route is one line. No per-route guard code. No “did we wrap this one?” review check.
The login form — Actions + bounce-back
📄 examples/ch05-routing/auth-flow/src/routes/Login.tsx
import { useActionState } from 'react';
import { useFormStatus } from 'react-dom';
import { useLocation, useNavigate } from 'react-router';
import { useAuth } from '@/auth/AuthProvider';
type State = { error: string | null };
export const Login = () => {
const { login } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const from = (location.state as { from?: string } | null)?.from ?? '/dashboard';
const submit = async (_prev: State, formData: FormData): Promise<State> => {
try {
await login(String(formData.get('email')), String(formData.get('password')));
navigate(from, { replace: true });
return { error: null };
} catch (err) {
return { error: err instanceof Error ? err.message : String(err) };
}
};
// useActionState returns [state, formAction, pending]; we destructure 2 of 3
// because <SignInButton> reads `pending` from useFormStatus() — that's the
// entire point of the Ch 2 §2.2 pending-via-context pattern. Compare with
// Ch 2's invoice example where the full 3-tuple is used.
const [state, formAction] = useActionState(submit, { error: null });
return (
<form action={formAction}>
<input name="email" type="email" required />
<input name="password" type="password" required />
<SignInButton />
{state.error && <p role="alert">{state.error}</p>}
</form>
);
};
const SignInButton = () => {
const { pending } = useFormStatus();
return <button disabled={pending}>{pending ? 'Signing in…' : 'Sign in'}</button>;
};
The Ch 2 patterns earn their keep here. useActionState handles pending and error state; useFormStatus lets the submit button know without prop-drilling.
The two alternative patterns — loader-based and beforeLoad
The guard-component approach above is one of three viable patterns. The other two:
Loader-based (React Router v7)
const protectedLoader = async () => {
if (!getCurrentUser()) throw redirect('/login');
return null;
};
const router = createBrowserRouter([
{
path: 'dashboard',
Component: Dashboard,
loader: protectedLoader, // ← guard via throw redirect
},
]);
Pro: the guard runs before the component mounts, so you don’t render a flash of “loading” or “checking auth”. Con: you have to remember to attach the loader to every protected route — there’s no compile-time enforcement.
beforeLoad (TanStack Router)
const dashboardRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/dashboard',
beforeLoad: ({ location }) => {
if (!getCurrentUser()) {
throw redirect({ to: '/login', search: { from: location.pathname } });
}
},
component: Dashboard,
});
Same shape, idiomatic for TanStack Router. Often hoisted to a parent route so child routes inherit the guard.
My recommendation: layout-route guard (<RequireAuth>) for component-driven codebases, loader / beforeLoad for data-router codebases where loaders are the norm. In the book’s example, the layout-route guard wins on clarity for new readers.
Logout
const { logout } = useAuth();
// ...
<button onClick={async () => {
await logout();
navigate('/');
}}>Sign out</button>
That’s the whole pattern. The provider’s setState triggers re-renders; any component reading useAuth() (including <RequireAuth>) sees the anonymous state on the next render. Currently-protected pages render <Navigate to="/login"> and the user lands at login.
Token storage — the trade-off
The example stores the authenticated user in React state. Refreshing the page acts as a clean logout. That’s the safest demo default; in production, three patterns:
httpOnlycookie set by your backend (recommended). The browser sends it automatically; JS can’t read it (XSS-resistant). Requires a backend or BFF — Ch 28 §28.1.localStorageJWT. Works in a pure SPA but is XSS-readable. Acceptable only for low-stakes apps with strict CSP.sessionStorageJWT. Same XSS risk, narrower lifetime (tab close clears it).
Cross-link Ch 28 §28.1 for the OIDC / BFF / silent-refresh upgrade.
🔹 5.6 Decision matrix — picking one
A single table of the questions that actually decide it.
| Question | Lean toward |
|---|---|
| Team already uses React Router and isn’t suffering? | React Router v7 |
| Need typed paths / params / search end-to-end? | TanStack Router |
| Heavy on URL-as-state (filters, multi-step wizards)? | TanStack Router |
| Planning to migrate to a framework (Next, Remix) later? | React Router v7 |
| Want minimal config, copy-from-the-docs setup? | React Router v7 |
| Comfortable with a bit of TS magic in exchange for safety? | TanStack Router |
| Building a dashboard with 50+ routes, many shared layouts? | Either; both scale |
| Going hybrid SPA + SSG (Ch 24)? | React Router v7 (or framework) |
| Need server-loader / action ergonomics today? | React Router v7 |
Where it’s a tie, React Router v7 is the safer bet: bigger community, more StackOverflow coverage, simpler hire-ramp.
🪤 Common Pitfalls
createBrowserRouterinside a component body. Recreated every render; the router thinks every render is a remount; state resets. Hoist to module scope.<BrowserRouter>and<RouterProvider>. Use one, not both;RouterProvideris the provider.- Forgetting
replace: trueon auth redirects. The unauthenticated URL stays in history; back button takes the user to a guarded page that immediately re-redirects. - Reading
location.statewithout a type guard. It’sunknown(anything could be there). Always check shape before using. - Mounting
<AuthProvider>inside<RouterProvider>. The router can render before the auth state is established. Order matters: provider outside, router inside. - Guarding only some protected routes. A single forgotten route is a security bug. Layout-route guards or a parent-level
beforeLoadare immune to “I forgot.” - Lazy-loading the route the user lands on most. The lazy chunk adds a click-to-fetch round-trip. Inline the most-visited route in the entry bundle; lazy-load the rest.
- Using
useAuth()in a loader. Loaders run outside the React tree — they don’t see contexts. Read the auth state from a non-React source (a singleton, a closure, or the cookie). - TanStack Router without the
declare moduleblock. Everything types asunknown; you’ll think the library is broken; it isn’t.
✅ Recap
- React Router v7 is the safe default. TanStack Router wins when typed paths and search-param state are non-negotiable.
- Data router mode (
createBrowserRouter) is the modern API for RR v7. Loaders run before render;useLoaderData()reads synchronously. - Code-split at the route boundary with
React.lazy+Component:(RR v7) orlazyRouteComponent(TanStack). Don’t lazy-load the route the user lands on. - Auth-guarded routes have three viable patterns: layout-route guard (
<RequireAuth>), loader-based (throw redirect),beforeLoad(TanStack). Pick by team familiarity; all three are correct. - Token storage trade-off matters: memory for demos,
httpOnlycookie via BFF for prod (Ch 28 §28.1). - The
useAuth()+ provider pattern is router-agnostic — the same hook works under either router.
🔗 Further Reading
- https://reactrouter.com — React Router v7 docs.
- https://tanstack.com/router — TanStack Router docs.
- Brooks Lybrand and Michael Jackson — “React Router v7 in production” talks.
- TKDodo — “Why I prefer TanStack Router” blog series.
- Ryan Florence — “Loaders and the data router” — the original Remix-era write-up that explains the design.
- Cross-references inside this book: Ch 2 §2.2 (Actions); Ch 13 (TanStack Query); Ch 28 §28.1 (enterprise auth).
In the book — not on the site
Each topic has an 🧠 Under-the-hood subsection — the algorithm, the data structures, what React DevTools surfaces, debugging recipes. Plus a 🧪 hands-on lab per chapter with a starter repo. Reserved for the book.