modern-react-spa

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> and useNavigate. Loaders are optional.
  • Data router mode (the modern default) — routes declared as a config tree; each route can declare a loader, an action, and Component. 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:

  1. Component: rather than element: <Component />. With Component, RR handles the JSX construction internally — and it composes with React.lazy so you can drop a code-split component straight in. The older element: syntax still works.
  2. 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.
  3. No BrowserRouter. RouterProvider is 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:

  1. 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.
  2. 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.
  3. 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

ConcernConfig-basedFile-based
DiscoverabilityNeed to read router.tsx to find a routels routes/ reveals the URL space
Refactor: rename a URLEdit the route configgit mv a file
Refactor: split out a featureMove the route blockgit mv a folder
ToolchainPure TS, no codegenBuild step generates a route tree at compile time
Layout nestingExplicit children: arrayImplicit by folder structure
Search-param schemasInline next to the routeSame — colocated in the file
Best forSmall/medium apps; teams that prefer explicit configLarge 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

  • state is a discriminated union, not a user | null. state.status === 'authenticated' narrows TypeScript so state.user is non-nullable inside the guard. No null-checks scattered across consumers.
  • useAuth() uses use(Context) (Ch 2 §2.3). It throws if called outside the provider — a common cause of “why is state undefined” bugs becomes a loud error.
  • The Promise<User> return on login lets 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. The replace flag 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:

  1. httpOnly cookie 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.
  2. localStorage JWT. Works in a pure SPA but is XSS-readable. Acceptable only for low-stakes apps with strict CSP.
  3. sessionStorage JWT. 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.

QuestionLean 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

  1. createBrowserRouter inside a component body. Recreated every render; the router thinks every render is a remount; state resets. Hoist to module scope.
  2. <BrowserRouter> and <RouterProvider>. Use one, not both; RouterProvider is the provider.
  3. Forgetting replace: true on auth redirects. The unauthenticated URL stays in history; back button takes the user to a guarded page that immediately re-redirects.
  4. Reading location.state without a type guard. It’s unknown (anything could be there). Always check shape before using.
  5. Mounting <AuthProvider> inside <RouterProvider>. The router can render before the auth state is established. Order matters: provider outside, router inside.
  6. Guarding only some protected routes. A single forgotten route is a security bug. Layout-route guards or a parent-level beforeLoad are immune to “I forgot.”
  7. 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.
  8. 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).
  9. TanStack Router without the declare module block. Everything types as unknown; 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) or lazyRouteComponent (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, httpOnly cookie 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.

Topics in this chapter (6)
  1. 5.1 React Router v7 — the established default
  2. 5.2 TanStack Router — the type-safe challenger
  3. 5.3 File-based vs config-based — trade-offs
  4. 5.4 Code-splitting at the route boundary
  5. 5.5 Auth-guarded routes — the book’s primary authentication example
  6. 5.6 Decision matrix — picking one