Skip to main content

Understanding Modern Routing in the React Ecosystem

· 77 min read
Pere Pages
Software Engineer
React Router v7 — modern routing in the React ecosystem

Routing in React has evolved from simple client-side navigation into a larger architectural concern. Today, routes can define not only which component appears for a URL, but also how data is loaded, how mutations happen, how errors are handled, and how layouts are nested.

The confusing part is that React Router no longer means only “the routing library”. With React Router v7, it can also mean a full framework model that continues many of the ideas previously associated with Remix.

React Router now documents three different modes:

  1. Declarative Mode
  2. Data Mode
  3. Framework Mode

See React Router docs — Picking a Mode.

The short history

PeriodMain ideaKey features it introducedTechnologies
Pre-2020Client-side routing maturesclient <Link> / <Route> navigation, URL params, route-based code-splitting; Reach Router: ranked + accessible matching; Next Pages: file-based routing + SSR/SSG (getServerSideProps / getStaticProps)React Router v3/v4/v5, Reach Router, Next.js Pages Router
2021Full-stack route modulesloader (read) + action (write), <Form> + progressive enhancement, automatic revalidation, web-standard Request/Response, nested data with SSR + streamingRemix
2022Data routers enter React RouterRemix's data model inside RR: createBrowserRouter, loader/action, useNavigation, useFetcher, deferred data (Await), error elements (v6 had added nested routes + <Outlet>)React Router 6.4
2022Server-first routing becomes mainstreamReact Server Components, nested layout.tsx, loading.tsx streaming, error.tsx, server-first data fetching (Server Actions soon after)Next.js 13 App Router
2024Remix merges back into React Routerroute modules as a framework, typed routes, the Vite plugin, SSR/SSG/SPA targets, per-route code-splittingReact Router v7 Framework Mode
2025+Two main models dominateconvergence — file-based routing, SSR + streaming, typed routes, and server mutations on both sidesReact Router Framework Mode vs Next.js App Router

React Router v7 is not just a normal major version. It is also the point where many Remix framework ideas move back under the React Router brand. The Remix team explained this in Merging Remix and React Router and React Router v7.

What exactly were the “Remix ideas”?

Nested routes and layouts were always React Router’s. What Remix added — and what Framework Mode now is — was the full-stack model built around them:

Remix ideaWhat it added
Route modulesone file per route, exporting its UI + data loader + mutation action + error boundary together
Web standardsbuild on the browser's own Request / Response / FormData / fetch instead of framework-specific request/response APIs (see below)
<Form>actionform submissions are handled by the route's action, and work before JS loads (progressive enhancement)
Automatic revalidationafter a successful action, the route's loaders re-run so the UI stays fresh
Server rendering & streamingSSR and streaming are first-class concerns, not add-ons
A build stepa compiler ties it together — today, the React Router Vite plugin

What "web standards" means here. Before this model, server-side React data code leaned on framework- or Node-specific objects: Express-style (req, res), Next.js's getServerSideProps({ req, res }) and NextApiRequest / NextApiResponse, bespoke body parsers, and each framework's own data-fetching API. Remix swapped those for the WHATWG Fetch APIs the browser already gives youRequest, Response, Headers, URL, FormData, and fetch. A loader receives a real Request; an action reads await request.formData() and returns a real Response:

// the same Request/Response/FormData you'd use in a browser — no framework-specific shapes
export async function action({ request }: { request: Request }) {
const form = await request.formData(); // web standard
await createUser({ name: String(form.get('name')) });
return new Response(null, { status: 204 }); // web standard
}

Because those objects are part of the web platform rather than one framework, the same code and the same knowledge run unchanged on Node, on edge runtimes (Cloudflare Workers, Deno), and even in a service worker — and there's simply less framework-specific API to learn.

How the browser routes by default — and what a router adds

Before any library, the browser is already a router: a URL maps to a document, and the browser knows how to fetch and display it. Seeing what it does natively makes clear exactly which thin layer React Router — or any client router — puts on top.

The default: every navigation is a full document load

Give the browser a URL — by clicking a link, submitting a form, or typing in the address bar — and it makes an HTTP request, throws the current page away, and loads a brand-new HTML document. The JavaScript runtime restarts from zero, in-memory (and React) state is lost, and there's a visible flash.

The browser also keeps a session history stack (so Back and Forward work), restores scroll position, and updates the address bar — all for free. The native pieces it exposes to scripts:

Browser primitiveWhat it does
<a href> / <form>declare a navigation; activating one triggers a full document load
window.locationread or set the current URL (setting it navigates)
History API — pushState, replaceState, popstatechange the URL without a request or reload, and observe Back/Forward
history stack + scrollBack/Forward and scroll restoration, handled automatically

The pivotal one is history.pushState: it changes the URL in the address bar without loading anything — but on its own it does nothing to the page. That gap is exactly what a client router fills.

The abstraction: simulate navigation in JavaScript

A single-page-application router is the glue between pushState and your UI. On every "navigation" it:

  1. intercepts the link click (calls preventDefault so the browser skips its full load),
  2. pushes the new URL with history.pushState,
  3. matches that URL to a component and renders it — no request, no reload, React state intact,
  4. listens for popstate so Back and Forward re-render the right route.

That's the whole trick. Every React Router primitive is a thin wrapper over a browser one:

You write……wrapping the browser's……so that
<Link to="…"><a href> + click intercept + pushStatenavigation happens with no full reload
<Routes> / <Route>URL-to-document matchinga URL renders a component instead of a fresh document
<Form><form> + submit intercepta submit runs an action as a client navigation (see Actions)
useNavigate()history.pushState / history.back()you navigate from code (see Navigation & URL state)

React Router even lets you choose the history backend behind all of this:

RouterHistory backendUse it for
BrowserRouterthe History API (clean URLs)normal web apps
HashRouterthe URL #fragmentstatic hosts that can't rewrite unknown paths to index.html
MemoryRouteran in-memory stack (no URL bar)tests and React Native (see Developer experience)

The mental model to carry through the rest of the post: a client router doesn't replace the browser's navigation — it simulates it in JavaScript. You keep the same URLs, Back/Forward, and bookmarking, but skip the full-document reload, so the JS runtime and React state survive across "page" changes. That survival is the whole point of an SPA — and it's also why you re-inherit a few jobs the browser used to handle for free (scroll restoration, focus management, announcing navigations to screen readers; see Developer experience).

Full (declarative) nested example

Almost everything client-side routing does — nested routes, layouts, index routes, links, and 404 handling — fits in one example. The rest of the post unpacks it.

// App.tsx
import {
BrowserRouter,
Link,
Outlet,
Route,
Routes,
} from 'react-router';

export function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<RootLayout />}>
<Route index element={<HomePage />} />

<Route path="dashboard" element={<DashboardLayout />}>
<Route index element={<DashboardHomePage />} />
<Route path="orders" element={<OrdersPage />} />
<Route path="settings" element={<SettingsPage />} />
</Route>

<Route path="*" element={<NotFoundPage />} />
</Route>
</Routes>
</BrowserRouter>
);
}

function RootLayout() {
return (
<div>
<header>
<nav>
<Link to="/">Home</Link> |{' '}
<Link to="/dashboard">Dashboard</Link> |{' '}
<Link to="/dashboard/orders">Orders</Link>
</nav>
</header>

<main>
<Outlet />
</main>
</div>
);
}

function DashboardLayout() {
return (
<section>
<aside>
<h2>Dashboard</h2>

<nav>
<Link to="/dashboard">Overview</Link> |{' '}
<Link to="/dashboard/orders">Orders</Link> |{' '}
<Link to="/dashboard/settings">Settings</Link>
</nav>
</aside>

<div>
<Outlet />
</div>
</section>
);
}

function HomePage() {
return <h1>Home</h1>;
}

function DashboardHomePage() {
return <h1>Dashboard overview</h1>;
}

function OrdersPage() {
return <h1>Orders</h1>;
}

function SettingsPage() {
return <h1>Settings</h1>;
}

function NotFoundPage() {
return <h1>404 - Not found</h1>;
}

For each URL:

URLRendered layout chain
/RootLayout → HomePage
/dashboardRootLayout → DashboardLayout → DashboardHomePage
/dashboard/ordersRootLayout → DashboardLayout → OrdersPage
/dashboard/settingsRootLayout → DashboardLayout → SettingsPage

What is an Outlet?

An <Outlet /> is a router-controlled slot where the matched child route is rendered.

See React Router docs — Outlet.

// RootLayout.tsx
import { Outlet } from 'react-router';

function RootLayout() {
return (
<>
<Header />
<Outlet />
<Footer />
</>
);
}

In the example above, the route tree for the dashboard part is:

// App.tsx (route tree excerpt)
<Route path="/" element={<RootLayout />}>
<Route path="dashboard" element={<DashboardLayout />}>
<Route path="orders" element={<OrdersPage />} />
</Route>
</Route>

Then /dashboard/orders renders conceptually like this:

// conceptual render tree — not a real file
<RootLayout>
<DashboardLayout>
<OrdersPage />
</DashboardLayout>
</RootLayout>

But technically, it happens through <Outlet />.

Key idea:

Parent routes render layouts.
Child routes render inside the parent route's <Outlet />.

If a parent route has children but does not render <Outlet />, the child route can match, but it will not appear in the UI.

Declarative routing and the three modes

The full example above uses JSX-based declarative routing — and React Router v7 still fully supports it. This model is still valid, especially for normal SPAs.

What changed is that declarative routing is now one of three modes — and they are not three different libraries. They are an upgrade path: each mode keeps everything from the previous one and gives the route more ownership.

Declarative Mode = URL → component
Data Mode = Declarative + loaders, actions, pending and error states
Framework Mode = Data + route modules, SSR, code splitting, typed routes

The clearest way to see the difference is the same route defined in each mode.

Declarative Mode — you render the router

// App.tsx
import { BrowserRouter, Route, Routes } from 'react-router';

export function App() {
return (
<BrowserRouter>
<Routes>
<Route path="users/:userId" element={<UserPage />} />
</Routes>
</BrowserRouter>
);
}

The route only picks a component for a URL. Data fetching, pending UI, and error handling live inside your components — the router knows nothing about them.

Data Mode — the router owns the data

// router.ts
import { createBrowserRouter } from 'react-router';

export const router = createBrowserRouter([
{
path: 'users/:userId',
loader: ({ params }) => getUser(params.userId),
Component: UserPage,
},
]);

Still a normal single-page application (SPA), but routes are now config objects, so the router can run loader() before rendering. That is what unlocks useLoaderData, action, and useNavigation (see Loaders, Actions and useNavigation).

Framework Mode — routes become files

// app/routes.ts
import { route } from '@react-router/dev/routes';

export default [
route('users/:userId', './routes/user.tsx'),
];

Each route now points at a route module (see Framework Mode), and the Vite plugin adds server-side rendering (SSR), code splitting, and typed params on top of everything Data Mode does. This is the former Remix model.

Side by side:

TopicDeclarative ModeData ModeFramework Mode
Setup<BrowserRouter>createBrowserRouterroutes.ts + Vite plugin
Route definitionJSXconfig objectsfiles / route modules
Data loadinginside componentsloader()loader()
Mutationsmanualaction()action()
Pending UImanual stateuseNavigation()useNavigation()
SSR / splitting / typesbuilt in
Best forsimple SPAsSPAs with datafull-stack apps

You can adopt Data Mode route by route in an existing SPA: swapping <BrowserRouter> for createBrowserRouter is a runtime change — no tooling is touched, and migrated and unmigrated routes can coexist while you convert them.

Framework Mode is different — it is a build-time decision. The React Router Vite plugin takes over your build: it compiles app/routes.ts and the route modules, generates the per-route types, code-splits every route, and optionally produces a server bundle for SSR. There is no "Framework Mode for just this one route" — you adopt a project structure and a toolchain, typically by starting from a Framework Mode template and moving routes into it.

Why is it called "Framework" Mode?

Because it stops being a library you call and becomes a framework that calls you. The Vite plugin plus the route-module contract (see Framework Mode) take over the whole app lifecycle — build, routing, data loading, mutations, rendering, and types — and invoke your exports at fixed points. You no longer call loader() or action(); the framework does, at the right moment. That inversion of control, applied at the app level, is exactly what makes something a framework rather than a library.

It is worth separating two ideas that are easy to conflate. "Framework" here means toolchain + ownership of the lifecycle — it does not mean "needs a server." Server rendering is the default, but it is a separate, switchable choice. Whether Framework Mode requires a server is its own question, answered in Framework Mode architecture.

The big mental-model shift

Early routing was mostly this:

URL → Component

Modern routing is closer to this:

URL → Route boundary → Data → Mutation handling → UI → Error handling

The route is no longer just a pointer to a component. In modern React Router, a route can own:

- the URL pattern
- the layout or page component
- the data loader
- the mutation action
- the pending/loading state
- the error boundary
- the nested route outlet

Loaders and useLoaderData

A loader fetches the route data.

useLoaderData() reads the data returned by the closest matched route loader.

See React Router docs — loader and React Router docs — useLoaderData.

// app/routes/users.tsx
import { useLoaderData } from 'react-router';

type User = {
id: string;
name: string;
};

export async function loader(): Promise<User[]> {
return getUsers();
}

export default function UsersRoute() {
const users = useLoaderData<typeof loader>();

return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}

Mental model:

loader() runs

React Router stores the result

useLoaderData() reads the result inside the matched route component

So:

loader = producer
useLoaderData = consumer

Loaders and caching

A natural question: does React Router cache loader data? By default, no — there is no persistent data cache. Loaders re-run on every navigation and revalidate after every action.

The only caching you get for free is per-visit: while you stay on a route, the loader runs once and useLoaderData() reads the stored result on each re-render — it does not re-run the loader.

EventLoader runs?
First navigation to the routeYes
Component re-render (state change)No — useLoaderData reads stored data
Navigate away and backYes (re-runs)
After a successful actionYes (revalidates)
shouldRevalidate returns falseNo

You can skip an unnecessary re-run with shouldRevalidate (one of the route-module exports from Framework Mode). See React Router docs — shouldRevalidate and React Router docs — clientLoader.

// app/routes/users.tsx
export function shouldRevalidate({ currentUrl, nextUrl }) {
// keep the cached loader data unless the search query changed
return currentUrl.search !== nextUrl.search;
}

Beyond that, real caching is opt-in, and you pick the layer:

- HTTP cache: a `headers` export sets Cache-Control on the loader response
- Client cache: a `clientLoader` reads/writes your own in-memory cache before hitting the server
- Library cache: wrap the fetch in TanStack Query or SWR for stale-while-revalidate

A clientLoader sketch — serve cached data instantly, fall back to the server loader:

// app/routes/users.tsx
const cache = new Map<string, unknown>();

export async function clientLoader({
request,
serverLoader,
}: {
request: Request;
serverLoader: () => Promise<unknown>;
}) {
const key = new URL(request.url).pathname;
if (cache.has(key)) return cache.get(key);

const data = await serverLoader();
cache.set(key, data);
return data;
}

This is the "simpler route/request model" referenced in React Router vs Next.js — React Router stays minimal and lets you choose the cache, whereas Next.js ships a heavier built-in framework cache (its model is in the Next.js App Router, the contrast in React Router vs Next.js).

Actions and mutations

If a loader is how a route reads data (see Loaders), an action is how it writes. It is the write counterpart to the loader: a function colocated with the route that runs when a non-GET request — usually, but not only, a <Form method="post"> — hits the route's URL. It reads FormData from a web-standard Request, performs the mutation, and returns a response. Then React Router automatically re-runs the active route loaders (all of them on the page by default; opt out per route with shouldRevalidate), so the UI reflects the change without you re-fetching anything.

The two words in the title are not the same thing — keep them apart:

action = the route handler React Router calls (the "where")
mutation = the actual data change you perform inside it (the "what")

The action is plumbing the router owns: it receives the request, hands you the form data, and triggers revalidation afterward. The mutation is your business logic — the createUser, updateOrder, or deleteItem call. One action can run several mutations, and the same mutation can be reused by many actions; the action is just where it gets wired to a URL.

See React Router docs — action.

// app/routes/new-user.tsx
import { Form } from 'react-router';

// the ACTION — the route handler the router calls on POST
export async function action({ request }: { request: Request }) {
const formData = await request.formData();

// the MUTATION — your actual data change
await createUser({
name: String(formData.get('name')),
});

return null;
}

export default function NewUserRoute() {
return (
<Form method="post">
<input name="name" />
<button>Create user</button>
</Form>
);
}

Forms are the common trigger, not the only one

The <Form> above is the usual way to reach an action, but the real rule is about the HTTP verb, not the element: a GET to the route runs its loader, and any non-GET (POST / PUT / PATCH / DELETE) runs its action. Whatever issues that request works:

  • useSubmit() — fire an action programmatically, with no <form> at all: from a button's onClick, a <select>'s onChange (autosave), or even an effect.
  • useFetcher().submit() / <fetcher.Form> — submit without navigating (the delete or like button — see useFetcher).
  • A JSON body, not just FormData — actions aren't tied to HTML form encoding.
import { useSubmit } from 'react-router';

function AutoSaveSort() {
const submit = useSubmit();
return (
<select
name="sort"
// no <Form>, no button — changing it POSTs to the route's action
onChange={(e) => submit({ sort: e.target.value }, { method: 'post' })}
>
<option value="recent">Recent</option>
<option value="oldest">Oldest</option>
</select>
);
}

By default submit sends FormData (read it with request.formData()); pass { method: 'post', encType: 'application/json' } to send a JSON body and read it with request.json() instead.

<Form> stays the default because it's the one trigger that also works before JavaScript loads (progressive enhancement, below) — but when you don't need that, an action is simply the route's write endpoint, reachable however you like. Next.js Server Actions are the same: usually a <form action={fn}>, yet you can also call the action straight from an event handler in a Client Component.

Why not just preventDefault?

The familiar way to handle a form is to take it over yourself: controlled state for each field, e.preventDefault(), a fetch, and your own pending/error flags.

// the manual way — you own everything
export default function NewUserRoute() {
const [name, setName] = useState('');
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);

async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setPending(true);
setError(null);
try {
await fetch('/api/users', {
method: 'POST',
body: JSON.stringify({ name }),
});
// ...and now manually re-fetch the user list so the UI isn't stale
await refetchUsers();
} catch (err) {
setError('Could not create user');
} finally {
setPending(false);
}
}

return (
<form onSubmit={handleSubmit}>
<input value={name} onChange={(e) => setName(e.target.value)} />
<button disabled={pending}>Create user</button>
</form>
);
}

That works, but every form makes you own four chores: controlled state per field, a pending/error state machine, the request, and — the one that's easy to forget — re-fetching whatever data the mutation just invalidated. A route action hands all four to the router:

ConcernManual onSubmitRoute action
Form datacontrolled useState per fieldFormData from <Form>
Pending statemanual useStateuseNavigation()
Errorstry/catch + local stateroute ErrorBoundary
Refresh after mutatemanual re-fetch / invalidateautomatic loader revalidation
Works without JSnoyes, in Framework Mode + SSR (progressive enhancement)

Because <Form> submits to a real URL, it can also work before JavaScript has loaded and enhance once it does — progressive enhancement you get for free. This last part needs a server to receive the submission, so it applies in Framework Mode with SSR; in a client-only SPA (Declarative or Data Mode) there's no server endpoint, so the form still needs JavaScript.

Common uses for actions:

- Create / update / delete (CRUD) forms (the everyday case)
- Optimistic UI and non-navigation submits via useFetcher
- Search or filter submissions that change server state
- Multi-step flows where each step writes and revalidates

Mental model:

Form submits

route action runs

mutation happens

route data can be revalidated

Why at the route level?

The natural question is why a mutation belongs to a route at all, rather than to a component or a standalone API call. Three reasons:

  • The URL already names the resource. A route like /projects/:id already identifies what it's about, so the route is the endpoint: a GET runs the loader (read), a POST runs the action (write). There's no separate API route to invent and keep in sync.
  • Read and write are colocated. The code that knows how to load a resource sits next to the code that knows how to mutate it — in the same route module (see Framework Mode).
  • Automatic revalidation is only possible because the router owns both. Since the router owns the loaders and the action, after a mutation it knows exactly which loaders to re-run. A loose fetch inside a component gives the router no idea what went stale — so you'd be back to invalidating caches by hand. The route boundary is what closes the write → read loop for free.
/projects/:id
GET → loader (read)
POST → action (write) → router revalidates the active loaders

This is the same idea as useLoaderData in Loaders, viewed from the write side: the route owns its data, so it can keep that data fresh after a change. Pending state during the submission comes from useNavigation.

useNavigation

Where useLoaderData answers what data do I have? (see Loaders), useNavigation() answers is the router doing something right now? It reports the router's current navigation or form submission, so you can render pending UI while loaders and actions are in flight.

See React Router docs — useNavigation.

// app/routes/users.tsx
import { useLoaderData, useNavigation } from 'react-router';

export async function loader() {
return getUsers();
}

export default function UsersRoute() {
const users = useLoaderData<typeof loader>();
const navigation = useNavigation();

const isLoading = navigation.state === 'loading';

return (
<section>
{isLoading && <p>Loading latest users...</p>}

<ul style={{ opacity: isLoading ? 0.5 : 1 }}>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</section>
);
}

Navigation states:

StateMeaning
idleNothing pending
loadingNavigation or loader revalidation is happening
submittingA form/action submission is happening

Why not just useState?

The manual way to show a spinner is a pending flag you flip yourself:

// the manual way — only works while you own the request
const [pending, setPending] = useState(false);

async function load() {
setPending(true);
await fetchUsers();
setPending(false);
}

That works only while you own the request. But in Data and Framework Mode the router owns the lifecycle: loaders run before the route renders, and actions trigger revalidation afterward. The in-flight state lives in the router, not your component — which often hasn't rendered the new route yet. useNavigation() reads the router's own state instead, so any component can reflect it:

ConcernManual useStateuseNavigation()
Who owns the in-flight stateyour componentthe router
Sees router navigations/revalidationnoyes
Readable from layouts / a global barno (prop-drill)yes (call it anywhere)

Common uses:

- A global loading / progress bar during navigation
- "Saving…" text and a disabled button during a submission
- Dimming stale data while loaders revalidate
- Optimistic UI from navigation.formData before the action resolves

The navigation object carries more than state:

navigation.state → idle | loading | submitting
navigation.location → where the router is going (during loading)
navigation.formData → what is being submitted (during submitting)

Short version:

useLoaderData() tells you: what data do I have?
useNavigation() tells you: is the router currently doing something?

Why is pending state global? (and when to use useFetcher)

useNavigation() is global: it describes the one navigation the whole router is currently performing, not a particular component or form. That follows from the router owning the lifecycle — "is something in flight?" is a router-level fact, so it's exposed once and any component can read it without prop-drilling.

The trade-off is that there is only one active navigation. Two independent submissions can't each have their own navigation.state. When you need isolated pending state — a row's delete button, a like button, a form that mutates without navigating — reach for useFetcher, which gives each fetcher its own state and formData.

See React Router docs — useFetcher.

// app/routes/users.tsx
import { useFetcher } from 'react-router';

function DeleteUserButton({ id }: { id: string }) {
const fetcher = useFetcher();
const isDeleting = fetcher.state !== 'idle';

return (
<fetcher.Form method="post" action={`/users/${id}/delete`}>
<button disabled={isDeleting}>
{isDeleting ? 'Deleting…' : 'Delete'}
</button>
</fetcher.Form>
);
}

Each DeleteUserButton tracks its own request, so deleting one row never disables the others — something a single global navigation.state cannot express.

useNavigation → the one navigation the whole router is performing
useFetcher → isolated state for a submit that does not navigate

Error handling

Data, mutations, and pending state each got their own section (see Loaders, Actions and useNavigation). Errors are the fourth thing a route owns — and the most under-appreciated, because the router catches a class of failure that React's own error boundaries can't.

The superpower: a route's error boundary catches anything thrown in its loader, its action, or its rendering. A plain React error boundary (componentDidCatch) only catches render errors — a failed data fetch slips right past it. Because the router owns the data lifecycle, it funnels async failures and render failures into the same place.

The error boundary

In Framework Mode, a route exports an ErrorBoundary beside its component. A loader can throw to short-circuit straight to it:

// app/routes/project.tsx
import { useRouteError, isRouteErrorResponse } from 'react-router';

export async function loader({ params }: { params: { id: string } }) {
const project = await getProject(params.id);
if (!project) {
throw new Response('Not found', { status: 404 }); // jump to the ErrorBoundary
}
return project;
}

export default function Project() {
/* normal render — never has to handle the error case */
}

export function ErrorBoundary() {
const error = useRouteError();

// an intentional thrown Response (404, 401, 403…) — expected control flow
if (isRouteErrorResponse(error)) {
return <p>{error.status}{error.statusText}</p>;
}

// anything else — an unexpected bug
return <p>Something went wrong.</p>;
}
  • useRouteError() returns whatever was thrown.
  • isRouteErrorResponse() distinguishes an intentional Response you threw (a 404/401 — part of your control flow) from an unexpected Error (an actual bug), so you can render a clean "Not found" for one and a generic fallback for the other.
  • Data Mode is the same idea under a different name: you pass an errorElement on the route object instead of exporting ErrorBoundary.

Nested boundaries contain the blast radius

An error bubbles up to the nearest route that has a boundary. Put one on a child route and a failure there renders fallback only in that route's <Outlet> slot — the parent layout (nav, sidebar, header) stays mounted and interactive. Put one only at the root and the whole screen becomes the fallback. That containment is the real advantage over a single top-level try/catch.

Throwing as control flow

Notice the loader throws instead of returning an error value. In the router, throwing from a loader or action is a first-class escape hatch:

throw new Response(null, { status: 404 }) → render the nearest ErrorBoundary
throw redirect('/login') → bounce to another route (auth — see “Routing as a layer”)

Returning is for the happy path; throwing hands control to the router.

Next.js: error.tsx, not-found.tsx, global-error

Next.js wires the same concepts through convention files in the segment folder:

ConcernReact RouterNext.js App Router
Catch errors in a subtreeErrorBoundary export / errorElementerror.tsx (a 'use client' boundary)
Retry after an errorre-navigate / revalidatethe reset() prop passed to error.tsx
404 / not-foundthrow new Response(null,{status:404})not-found.tsx + notFound()
Catch errors in the root layoutroot route ErrorBoundaryglobal-error.tsx

Like RR boundaries, error.tsx files nest: the one closest to the failing segment handles it, so a broken widget doesn't blank the whole page.

With this, the route owns all four of its concerns — data (see Loaders), mutations (see Actions), pending state (see useNavigation), and errors (here). The CRUD app puts all four to work at once.

Loaders, Actions, and useNavigation covered how a route loads, mutates, and reports its data. This section fills the everyday gap between them: how you move between routes in code and read or write the URL itself — path params and query strings.

Moving between routes

<Link> is the declarative way (see the declarative example and Outlet). For everything else, React Router gives you three tools:

import { useNavigate, NavLink, Navigate, Outlet } from 'react-router';

function Toolbar() {
const navigate = useNavigate();

return (
<>
{/* active-aware link: styles itself when its route matches */}
<NavLink
to="/dashboard"
className={({ isActive }) => (isActive ? 'active' : undefined)}
>
Dashboard
</NavLink>

{/* imperative navigation — e.g. after a non-form side effect */}
<button onClick={() => navigate('/dashboard/orders')}>Go to orders</button>
<button onClick={() => navigate(-1)}>Back</button>
</>
);
}

// declarative redirect, rendered as an element
function RequireFlag({ ok }: { ok: boolean }) {
return ok ? <Outlet /> : <Navigate to="/" replace />;
}
  • useNavigate() — programmatic navigation: navigate(to), navigate(-1) (history), navigate(to, { replace: true }).
  • <NavLink> — a <Link> that knows whether it's active, for nav menus.
  • <Navigate> — a render-time redirect (the component form of redirect() from a loader or action).

A route's path params and its query string are both state that lives in the URL. React Router reads each with a hook:

import { useParams, useSearchParams } from 'react-router';

function UserPage() {
// /users/:userId → { userId: '42' }
const { userId } = useParams();

// ?tab=settings → read + write, like useState for the URL
const [searchParams, setSearchParams] = useSearchParams();
const tab = searchParams.get('tab') ?? 'profile';

return (
<>
<h1>User {userId}</h1>
<button onClick={() => setSearchParams({ tab: 'settings' })}>Settings</button>
<p>Active tab: {tab}</p>
</>
);
}

useSearchParams is the "URL as state" pattern: the query string becomes shareable, bookmarkable component state, and changing it is a navigation the router tracks — so useNavigation sees it like any other.

The Next.js equivalents

Next.js splits the same job across hooks from next/navigation, and because they read live browser state, they only run in Client Components ('use client'):

'use client';
import { useRouter, usePathname, useParams, useSearchParams } from 'next/navigation';

function Toolbar() {
const router = useRouter(); // router.push('/x'), router.replace, router.back
const pathname = usePathname(); // '/users/42'
const { userId } = useParams(); // { userId: '42' } from [userId]
const searchParams = useSearchParams(); // read-only URLSearchParams

const tab = searchParams.get('tab') ?? 'profile';
// ...
}
NeedReact RouterNext.js (next/navigation)
Programmatic navigationuseNavigate()useRouter()push / replace
Active-aware link<NavLink><Link> + usePathname()
Declarative redirect<Navigate> / redirect()redirect() (next/navigation)
Read path paramuseParams()useParams() (or params prop)
Read current pathuseLocation()usePathname()
Read / write query stringuseSearchParams() (r/w)useSearchParams() (read-only)

The biggest gotcha: Next's useSearchParams() is read-only — to change the query you build a new string and router.push() it; React Router's hook hands you the setter directly.

Optimistic UI & streaming data

Two capabilities the router unlocks that earlier sections only hinted at: showing a result before the server confirms it, and rendering a route before all its data has arrived.

Optimistic UI

Actions mentioned navigation.formData and fetcher.formData in passing. Their real point is optimistic UI: a submission carries its own payload, so you can render the expected result immediately and let revalidation reconcile it. A fetcher (see useFetcher) keeps this local to one control:

import { useFetcher } from 'react-router';

function FavoriteButton({ item }: { item: { id: string; favorited: boolean } }) {
const fetcher = useFetcher();

// while submitting, trust the value we just sent instead of the stale prop
const favorited = fetcher.formData
? fetcher.formData.get('favorited') === 'true'
: item.favorited;

return (
<fetcher.Form method="post" action={`/items/${item.id}/favorite`}>
<input type="hidden" name="favorited" value={String(!favorited)} />
<button>{favorited ? '★ Favorited' : '☆ Favorite'}</button>
</fetcher.Form>
);
}

The star flips the instant you click — no spinner — because the UI reads fetcher.formData. If the action fails, revalidation restores the real value. This is the modern-routing answer to hand-rolled "local state + manual rollback".

Streaming / deferred data

A loader normally awaits everything before the route renders (see Loaders). For a page with one slow part — reviews under a fast-loading product — that makes the whole route wait. React Router v7 lets a loader return a promise for the slow part and render the rest immediately; <Await> + <Suspense> resolve it in place (in v7 you return the promise directly — no defer() helper needed):

// app/routes/product.tsx
import { Await } from 'react-router';
import { Suspense } from 'react';

export async function loader() {
const product = await getProduct(); // fast: awaited now
const reviews = getReviews(); // slow: a promise, NOT awaited
return { product, reviews };
}

export default function Product({ loaderData }) {
return (
<>
<h1>{loaderData.product.name}</h1>

<Suspense fallback={<p>Loading reviews…</p>}>
<Await resolve={loaderData.reviews}>
{(reviews) => <ReviewList reviews={reviews} />}
</Await>
</Suspense>
</>
);
}

This is the React Router side of the same streaming idea shown in the Next.js App Router with <Suspense> and loading.tsx: the shell paints now, the slow piece streams in when ready.

ApproachWhole route waits?How the slow part renders
Plain await in loaderyesonly after everything resolves
Return a promise + Awaitno<Suspense> fallback, then streamed in

Framework Mode: routes become modules

Everything so far — nested routes, outlets, loaders, actions, navigation state — works in Data Mode. Framework Mode takes the final step: it gives all of it a home, the route module.

High-level definition:

A route is a URL-driven application boundary that owns a slice of UI, data loading, mutations, errors, and nesting.

In Framework Mode, React Router describes routes as being defined by a URL pattern and a route module. See React Router docs — Framework Mode routing.

// app/routes.ts
import { route } from '@react-router/dev/routes';

export default [
route('users/:userId', './routes/user.tsx'),
];
// app/routes/user.tsx
export async function loader() {
return getUser();
}

export async function action() {
return updateUser();
}

export default function UserRoute() {
return <UserPage />;
}

export function ErrorBoundary() {
return <p>Something went wrong</p>;
}

The loader and action here are exactly the ones from Loaders and Actions — Framework Mode just co-locates them with the component and the error boundary in one file.

The full example, rewritten in Framework Mode

Here is the dashboard app from the declarative example again — same URLs, same nesting — as a Framework Mode project. One JSX tree becomes a route config plus one file per route:

app/
├── root.tsx ← RootLayout (nav + <Outlet />)
├── routes.ts ← the route config
└── routes/
├── home.tsx
├── dashboard.tsx ← DashboardLayout (aside + <Outlet />)
├── dashboard-home.tsx
├── orders.tsx
├── settings.tsx
└── not-found.tsx

The route tree moves out of JSX into config:

// app/routes.ts
import { type RouteConfig, index, route } from '@react-router/dev/routes';

export default [
index('./routes/home.tsx'),

route('dashboard', './routes/dashboard.tsx', [
index('./routes/dashboard-home.tsx'),
route('orders', './routes/orders.tsx'),
route('settings', './routes/settings.tsx'),
]),

route('*', './routes/not-found.tsx'),
] satisfies RouteConfig;

app/root.tsx plays the RootLayout role (it also exports the <html> document shell, omitted here):

// app/root.tsx
import { Link, Outlet } from 'react-router';

export default function Root() {
return (
<div>
<header>
<nav>
<Link to="/">Home</Link> |{' '}
<Link to="/dashboard">Dashboard</Link> |{' '}
<Link to="/dashboard/orders">Orders</Link>
</nav>
</header>

<main>
<Outlet />
</main>
</div>
);
}

DashboardLayout becomes a layout route — a route module with an <Outlet />:

// app/routes/dashboard.tsx
import { Link, Outlet } from 'react-router';

export default function DashboardLayout() {
return (
<section>
<aside>
<h2>Dashboard</h2>

<nav>
<Link to="/dashboard">Overview</Link> |{' '}
<Link to="/dashboard/orders">Orders</Link> |{' '}
<Link to="/dashboard/settings">Settings</Link>
</nav>
</aside>

<div>
<Outlet />
</div>
</section>
);
}

And the leaf pages are route modules. This is where Framework Mode pays off — OrdersPage can now own its data, with typed params and loader data generated per route:

// app/routes/orders.tsx
import type { Route } from './+types/orders';

export async function loader() {
return { orders: await getOrders() };
}

export default function OrdersPage({ loaderData }: Route.ComponentProps) {
return (
<>
<h1>Orders</h1>
<ul>
{loaderData.orders.map((order) => (
<li key={order.id}>{order.title}</li>
))}
</ul>
</>
);
}

The remaining leaves (home.tsx, dashboard-home.tsx, settings.tsx, not-found.tsx) are plain one-component modules, exactly like their section-2 counterparts:

// app/routes/home.tsx
export default function HomePage() {
return <h1>Home</h1>;
}

Compare the two versions and the trade becomes visible: the declarative example keeps the whole app in one file you can read top to bottom; Framework Mode trades that for per-route ownership — each file can grow a loader, an action, or an ErrorBoundary without touching any other route.

The route module contract — a template method pattern

Framework Mode is convention-driven: you never call loader() or action() yourself. The framework owns the request/navigation lifecycle and calls your exports at fixed points — exactly the shape of the template method pattern: the algorithm is fixed, you fill in the steps.

The full contract a route module can implement (see React Router docs — Route Module):

ExportRoleRuns on
defaultThe route componentServer + client
loaderRead data before renderingServer
clientLoaderRead data in the browserClient
action / clientActionHandle mutationsServer / client
ErrorBoundaryError UI when any export throwsServer + client
HydrateFallbackUI while client data loads on hydrateClient
headers, meta, linksHTTP headers and <head> contentServer
middleware / clientMiddlewareRun around loaders and actionsServer / client
handle, shouldRevalidateRoute metadata, revalidation control

About file naming conventions: v7’s default is the explicit app/routes.ts config shown above — not magic filenames. The Remix-style flat-file convention (users.$userId.tsx) still exists as an opt-in package, @react-router/fs-routes.

Should you adopt Framework Mode?

That "convention-driven" property is also its biggest team benefit — and the strongest reason to reach for it goes beyond the feature list. Because the route module fixes where data loading, mutations, errors, and pending state live, everyone writes them the same way. Without that contract, one developer fetches in useEffect, another behind a custom hook, a third in TanStack Query; error handling and form posting each sprout three flavors. The contract collapses those choices into one prescribed shape — which is what keeps a large codebase consistent, reviewable, and quick to onboard into.

Keep one thing honest, though: you get much of that consistency from Data Mode too — it's the same loader / action / ErrorBoundary contract, just without the toolchain. Framework Mode's extra step is making that structure the enforced project default, plus SSR, typed routes, and code-splitting. So the real choice isn't "Framework Mode or chaos."

With that framing, for a new full-stack app, Framework Mode is the sensible default nowadays — it's what create-react-router scaffolds. And "I don't want a server" is not a reason to avoid it, because ssr: false (see Framework Mode architecture) gives you the same structure as a pure SPA. The cases where it's genuinely better not to:

Reach for Framework Mode when…Stay in Declarative / Data Mode when…
New full-stack app, dashboard, or authenticated CRUDIt's a small SPA where the Vite-plugin toolchain is pure overhead
You want one enforced way to load / mutate / handle errorsThe team already standardizes on its own data + routing stack
You'll use SSR, SSG, typed routes, or per-route code-splittingYou can't commit to a build-time, all-in switch (see the three modes)
Consistency and onboarding matter more than per-route freedomYou're embedding a widget / micro-frontend you don't fully own

The throughline from the three modes still holds: the modes are an upgrade path, so you can start Declarative, adopt the Data Mode contract once consistency starts to matter, and move to Framework Mode when the toolchain pays for itself — not before.

Framework Mode architecture: SSR, hybrids, and microfrontends

Does Framework Mode require a server?

The intuitive guess is yes — "framework, therefore a Node server, therefore impossible to run everything in the client." That guess is wrong. As the three modes framed it, Framework Mode is a toolchain decision, and the rendering target is a separate config choice. The same route modules can ship in three ways:

- SSR (default) → a server runs loaders/actions per request
- SSG (prerender) → static site generation: static HTML built ahead of time, no runtime server
- SPA (ssr:false) → one index.html, everything runs in the browser, no server

The full target matrix and the ssr / prerender knobs are in Hybrid approaches below. The point here: ssr: false gives you exactly the "dummy client-only" mode — Framework Mode with no server at all. You keep the route modules, typed routes, and code splitting; you just give up server rendering.

So the server only enters when SSR is on (the default). When it is:

- loaders and actions run on a server runtime, per request
- the build emits a server bundle you deploy on Node or an edge runtime (via an adapter)
- server-only work — DB queries, secrets, private APIs — lives safely in loader/action

With ssr: false that all moves to the browser: a loader becomes a clientLoader, there is no server bundle to deploy, and you host the static output on any CDN. The mode is the same; only where the code runs changes.

How Framework Mode affects SSR

SSR is on by default (ssr: true in react-router.config.ts). The key effect is where loaders run:

First request → server runs loaders → streams HTML → client hydrates
Navigation → client fetches loader data → router swaps the outlet

Here hydration is the client step that turns the server's inert HTML into a live React app — downloading the JS, re-running the components, and wiring up event handlers. Rendering & hydration covers it, and its performance cost, in full.

So the same route module serves the document on first load and behaves like an SPA afterwards. Per-route headers and streaming come with it, and every route module is automatically a code-split bundle.

Hybrid approaches

Yes — rendering strategy is a per-app (and partly per-path) decision via two config options (see React Router docs — Pre-rendering):

// react-router.config.ts
import type { Config } from '@react-router/dev/config';

export default {
ssr: true, // runtime server (default)
prerender: ['/', '/blog'], // these paths become static HTML at build time
} satisfies Config;
StrategyssrprerenderResult
Full SSRtrueEvery request rendered by the server
SSG + servertruepathsStatic HTML for chosen paths, server for rest
SPA modefalseOne index.html, everything client-side
SSG + SPA fallbackfalsepathsStatic HTML for chosen paths, SPA for the rest

There is also a per-route hybrid: a route can export both loader (server) and clientLoader (browser) and decide which one feeds it after hydration.

What about microfrontends?

Route modules give you many of the things teams want from microfrontends — within a single deployable: each route is a code-split bundle with clear ownership of UI, data, and errors, so teams can own route subtrees as vertical slices.

What Framework Mode does not give you is independent deployment. If you need true microfrontends:

- One app per path prefix, each with its own router (basename) — simplest
- A shell router + Module Federation, loading remote routes lazily (Data Mode's
lazy route definitions fit this well)
- Avoid two routers owning the same URL — exactly one router must own navigation

A practical reading: Framework Mode competes with the reasons people reach for microfrontends (team boundaries, bundle isolation) more than it integrates with the architecture itself.

Why “React Router formerly Remix” is confusing

That merge is exactly why the name trips people up — so before turning to Next.js, it's worth untangling it.

Historically:

React = UI library
React Router = routing library for React
Remix = full-stack framework built on React Router

Later, Remix and React Router converged. React Router v7 now includes the former Remix-style framework model.

So today:

React Router library mode = routing library for React
React Router framework mode = former Remix-style full-stack React framework

The name is confusing because “React Router” used to mean only the library. Now it can also mean the framework.

A useful shortcut:

Read “React Router Framework Mode” as “Remix-style React framework under the React Router brand.”

The Next.js App Router way

So far this post has been a React Router tour. Next.js App Router is the other model that dominates the "2025+" row of the timeline — and the cleanest way to learn it is to build the same Projects app we build with React Router in the CRUD app, now the Next.js way. Where React Router is route/request-centric, Next.js is component-tree-centric, built on React Server Components (RSC). Everything below targets Next.js 15.

Routing is the file system

There is no routes.ts. A folder under app/ is a URL segment, and a handful of special filenames give each segment its parts:

app/
└── projects/
├── layout.tsx ← shared chrome + <Outlet/> equivalent
├── page.tsx ← /projects (the list)
├── loading.tsx ← streamed pending UI
├── error.tsx ← error boundary
├── new/
│ └── page.tsx ← /projects/new
└── [projectId]/
├── page.tsx ← /projects/:projectId
└── edit/
└── page.tsx ← /projects/:projectId/edit
FileRoleReact Router analog
page.tsxthe route's UIroute module default export
layout.tsxpersistent nested shelllayout route + <Outlet />
loading.tsxSuspense fallback while the page awaitsuseNavigation pending UI
error.tsxerror boundary (must be a Client Comp.)route ErrorBoundary
not-found.tsxUI for notFound()thrown 404 Response
[projectId]dynamic segment:projectId param

Layouts replace <Outlet />

A layout.tsx receives the matched child segment as a children prop and wraps it. There is no <Outlet /> component — children is the outlet:

// app/projects/layout.tsx
import Link from 'next/link';

export default function ProjectsLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<section>
<header>
<h1>Projects</h1>
<Link href="/projects/new">New project</Link>
</header>

{children}
</section>
);
}

Data loads in the component

There is no loader and no useLoaderData. A page.tsx is an async Server Component that awaits its data directly — the function body runs on the server, so it can touch the database or secrets and none of that code ships to the browser:

// app/projects/page.tsx
import Link from 'next/link';

export default async function ProjectsList() {
const projects = await db.projects.all(); // runs on the server

return (
<ul>
{projects.map((project) => (
<li key={project.id}>
<Link href={`/projects/${project.id}`}>{project.name}</Link>
</li>
))}
</ul>
);
}

The dynamic route reads its param from params — which in Next.js 15 is a promise you await — and calls notFound(), the App Router's equivalent of throwing a 404 Response:

// app/projects/[projectId]/page.tsx
import { notFound } from 'next/navigation';

export default async function ProjectDetail({
params,
}: {
params: Promise<{ projectId: string }>;
}) {
const { projectId } = await params;
const project = await db.projects.find(projectId);
if (!project) notFound();

return <h2>{project.name}</h2>;
}

Mutations are Server Actions

React Router's action is a route-level export. Next.js instead uses Server Actions'use server' functions you hand directly to a <form action={...}>. The function runs on the server, reads FormData, mutates, then revalidates and/or redirects:

// app/projects/new/page.tsx
import { redirect } from 'next/navigation';
import { revalidatePath } from 'next/cache';
import { SubmitButton } from './submit-button';

export default function ProjectNew() {
async function createProject(formData: FormData) {
'use server';
const name = String(formData.get('name') ?? '').trim();
if (!name) return;

await db.projects.create({ name });
revalidatePath('/projects'); // re-render the list
redirect('/projects');
}

return (
<form action={createProject}>
<input name="name" />
<SubmitButton />
</form>
);
}

Two differences from React Router stand out:

ConcernReact RouterNext.js App Router
Where it livesroute-level action exporta 'use server' function passed to a <form>
Revalidationautomatic loader re-runexplicit revalidatePath / revalidateTag
Pending stateuseNavigation / useFetcheruseFormStatus / useActionState

Pending and validation state come from React 19 hooks in a small Client Component: useActionState gives a component the action's return value (e.g. field errors) plus an isPending flag, and useFormStatus reads the parent form's pending state from a nested button — the Next.js analogs of useNavigation:

// app/projects/new/submit-button.tsx
'use client';
import { useFormStatus } from 'react-dom';

export function SubmitButton() {
const { pending } = useFormStatus();
return <button disabled={pending}>{pending ? 'Saving…' : 'Create'}</button>;
}

Loading and errors are files

The pending and error UI React Router wires through hooks and route exports, Next.js wires through conventional files in the same folder:

loading.tsx → shown (streamed) while the page's async work resolves
error.tsx → 'use client' boundary that catches anything the segment throws
not-found.tsx → rendered when a segment calls notFound()

Because the page is a Server Component, the server can stream: it sends the shell immediately, shows loading.tsx for the parts still awaiting, and streams each piece in as its data resolves — no client-side "fetch then setState" needed.

The caching model

The caching discussion noted React Router ships no framework data cache. Next.js is the opposite extreme — this is the "more powerful, more complex framework cache" the comparison below points at. It is really four caches:

CacheStoresWhereNext.js 15 default
Request Memoizationidentical fetch() within one renderserver, per requeston
Data Cachefetch results across requestsserveroff — opt in with cache: 'force-cache' or next: { revalidate }
Full Route Cacherendered RSC/HTML of static routesserver / buildstatic by default; a dynamic API opts a route out
Router Cachealready-visited route segmentsbrowsershort-lived

Next.js 15 deliberately shifted these defaults toward explicit opt-in: fetch is no longer cached by default, and GET Route Handlers aren't either — a reader who learned Next.js 14 should note the change. The trade is the mirror image of React Router: you get a powerful built-in cache, but you have to learn its rules rather than reaching for TanStack Query or SWR yourself.

The whole request, end to end

Advanced Next.js routing

We met the everyday App Router earlier. A few more folder conventions unlock routing patterns that have no direct React Router equivalent — worth knowing, because they are where the file-system model earns its keep.

Route groups — organize without changing the URL

Wrap a folder name in parentheses and it groups files without adding a URL segment — handy for giving sections their own layout:

app/
├── (marketing)/ ← group, not part of the URL
│ ├── layout.tsx ← marketing-only chrome
│ ├── page.tsx ← /
│ └── about/page.tsx ← /about
└── (app)/
├── layout.tsx ← authed app chrome
└── dashboard/page.tsx ← /dashboard

Parallel routes — render several slots at once

A folder prefixed with @ is a slot the layout receives as a named prop, so one URL can render multiple independent subtrees — each with its own loading and error states:

app/dashboard/
├── layout.tsx ← receives { children, team, analytics }
├── @team/page.tsx
├── @analytics/page.tsx
└── default.tsx ← fallback for unmatched slots
// app/dashboard/layout.tsx
export default function Layout({
children,
team,
analytics,
}: {
children: React.ReactNode;
team: React.ReactNode;
analytics: React.ReactNode;
}) {
return (
<>
{children}
<aside>
{team}
{analytics}
</aside>
</>
);
}

Intercepting routes — the modal-over-list pattern

Prefix a segment with (.) (same level), (..) (one up), and so on to intercept a URL and render it in place on a soft navigation, while a hard load still shows the full page. The classic use is a photo that opens as a modal over the gallery but is a standalone page when linked directly:

app/gallery/
├── page.tsx ← the grid
├── [id]/page.tsx ← full photo page (hard load / refresh)
└── (.)[id]/page.tsx ← same photo as a modal (soft nav from the grid)

The rest, briefly

FeatureWhat it's forReact Router analog
middleware.tsrun before render — auth, locale, redirectsa guard loader / middleware
generateStaticParamswhich dynamic params to prerender at build (SSG)prerender config paths
generateMetadataper-route <head> / SEO tagsmeta export (see Routing as a layer)
Route Handlers route.tsHTTP endpoints (GET/POST…) in the app/ treeaction + loader / resource routes

middleware.ts, generateMetadata, and locale handling all come back in Routing as a layer, where routing meets the layers around it.

With both models now on the table on their own terms, the next section puts them head to head.

The third option: TanStack Router & TanStack Start

The post's title says the React ecosystem, and the comparison so far has been a two-horse race. The notable third option is TanStack Router — and its full-stack companion, TanStack Start. Its defining bet is end-to-end type safety: routes, path params, and search params are fully typed and inferred, so a typo in a link or a missing query param is a compile error, not a runtime surprise.

TanStack Router (the client router)

// a route definition — params and search are typed and validated
import { createFileRoute } from '@tanstack/react-router';

export const Route = createFileRoute('/users/$userId')({
// validate + type the query string
validateSearch: (search) => ({ tab: (search.tab as string) ?? 'profile' }),
loader: ({ params }) => getUser(params.userId), // params.userId is typed: string
component: UserPage,
});

function UserPage() {
const { userId } = Route.useParams(); // typed
const { tab } = Route.useSearch(); // typed + validated
// ...
}

What stands out versus React Router and Next.js:

  • Typed navigation<Link to="/users/$userId" params={{ userId }} /> is checked against the route tree; an unknown path or a missing param won't compile.
  • First-class search params — the query string is typed, validated, and managed like state (think useSearchParams from Navigation & URL state, but schema-validated) — a weak spot in the other two.
  • Built-in loaders + caching with staleTime / gcTime, pairing naturally with TanStack Query.
  • Excellent devtools — a route-inspection panel neither React Router nor Next.js ships officially.

TanStack Start (the full-stack framework)

TanStack Start is the meta-framework built on the router — the peer of React Router Framework Mode and Next.js App Router. It adds SSR and streaming, server functions (typed RPC, the rough analog of an action / Server Action), and a Vite + Nitro build that deploys to Node or the edge. It is newer and still maturing, so it trades some of the others' ecosystem depth for its type-safety-first design.

Where it fits

AxisReact Router v7Next.js App RouterTanStack Router / Start
Core betroute/request modelRSC component treeend-to-end type safety
Route definitionJSX / config / filesfile systemcode or file-based (typed)
Search paramsuseSearchParams (untyped)useSearchParams (untyped)typed + validated
Data loadingloaderServer Componentsloader (+ TanStack Query)
Full-stack layerFramework ModeApp Router (built in)TanStack Start (newer)
Server requiredoptionaleffectively requiredoptional (Start adds SSR)
Maturityvery maturevery maturerouter mature, Start maturing

The honest summary: reach for TanStack Router when type safety and powerful, validated search params matter and you want a pure client router; consider TanStack Start when you want that same type-first ergonomics in a full-stack framework and can accept a younger ecosystem.

See TanStack Router docs and TanStack Start docs.

React Router v7 vs Next.js App Router

Now that both models have been built on their own terms — React Router and Next.js each covered in full earlier — here they are head to head. Both React Router v7 Framework Mode and Next.js App Router solve route-level data loading, but they use different abstractions. (A third contender, TanStack Router, covered just above.)

Next.js App Router is documented in Next.js docs — App Router. Its model depends heavily on Server Components, documented in Next.js docs — Server and Client Components, and mutations through Server Actions, documented in Next.js docs — Updating Data.

They rhyme on the surface — but the core model differs

After all this, it's tempting to conclude the two are the same framework with different names. Nearly every concept seems to have a counterpart:

React RouterNext.js App Router
loaderasync Server Component
actionServer Action
ErrorBoundaryerror.tsx
useNavigationloading.tsx
<Outlet />layout.tsx

That mapping is real — but it is a surface analogy, not the same machinery. The structural difference is where the server/client boundary lives:

  • React Router is route/request-centric. A loader is a plain function that returns data; your components are ordinary React, rendered the same way on the server and the client. The split happens at the route module — the whole route runs on the server (loader) or in the browser (clientLoader).
  • Next.js App Router is component-centric, built on React Server Components (RSC). Data fetching lives inside async Server Components, and the boundary runs through your component tree: components are server components by default, and you opt individual subtrees into the client with 'use client'.

The same screen makes the difference concrete:

// React Router — the boundary IS the route module
export async function loader() { // runs on the server
return getUser();
}

export default function UserRoute() { // ordinary React: same on server + client
const user = useLoaderData<typeof loader>();
return <Profile user={user} />;
}
// Next.js — the boundary runs THROUGH the component tree
async function UserPage() { // Server Component: can await data directly
const user = await getUser();
return <Profile user={user} />;
}

// profile.tsx
'use client'; // this subtree hydrates and runs on the client
export function Profile({ user }) {
// useState, onClick, effects… live here
}

The matching vocabulary hides a different architecture. In React Router you reason about routes and requests; in Next.js about a server-rendered component tree. As the server posture below shows, caching, server requirements, and where your code runs all follow from this one difference.

TopicReact Router v7 Framework ModeNext.js App Router
Core mental modelRoute/request-centricServer Component tree-centric
Data loadingloader()async Server Components
Mutationsaction() + forms/fetchersServer Actions / Route Handlers
Main boundaryRoute moduleServer/client component boundary
Pending UIuseNavigation, useFetcherloading.tsx, Suspense
ErrorsRoute ErrorBoundaryerror.tsx
LayoutsRoute hierarchy + <Outlet />Nested layout.tsx files
CachingSimpler route/request modelMore powerful, more complex framework cache
Best fitCRUD apps, dashboards, authenticated appsSEO-heavy, content-heavy, Vercel-first apps

Where routing runs: client vs server

The mapping table says what each piece is called, not where it runs when you click a link. That is the most concrete difference between the two — and it starts with the classic split between server-side and client-side routing:

Both React Router and Next.js do client-side routing after the first load — neither does full page reloads on navigation. What differs is how much the server is still involved on each navigation.

React Router serves a static shell (or, in Framework Mode, a server-rendered first paint), then the client router owns every navigation. The server is only re-contacted if the destination route has a loader that fetches data:

Next.js App Router also soft-navigates on the client, but because routes are Server Components, the client router fetches a freshly server-rendered payload for the new segment on every navigation — a server round-trip happens even though the page never fully reloads:

The sharpest practical contrast is what a navigation costs:

FrameworkFirst loadNavigation handled byServer hit on navigation?
React Router — Declarative / Data (SPA)static shell + JS bundleclient routeronly if the route has a loader
React Router — Framework Mode (SSR)server-rendered HTMLclient routeryes — to fetch loader data
Next.js — App Routerserver-rendered RSC payloadclient routeryes — to render the segment's RSC

The takeaway: a React Router SPA navigation can be fully client-side (no server unless you fetch), while a Next.js navigation always re-renders the new segment on the server to produce its RSC payload. That one fact is why React Router can ship with no server at all, and why Next.js leans on its framework cache (see the Next.js App Router) to keep those per-navigation server renders cheap.

The server posture is the deepest difference

The table above contrasts abstractions; the sharper distinction is whether a server is required at all. This is the precise point behind the "does it need a server?" question from Framework Mode architecture — and the answer differs between the two.

AxisReact Router v7 Framework ModeNext.js App Router
Server requirementOptional — SSR is default, but SPA/SSG need no serverEffectively required — RSC assumes a server runtime
Where data runsloader/action on server, or clientLoader/clientAction in the browserasync Server Components + Server Actions run on the server
Deploy targetAny Node/edge host via adapters, or static/SPA to a CDNNode/edge runtime; first-class on Vercel
Pure-client optionYes (ssr: false)Not the App Router model (static export is constrained)

React Router is server-optional: rendering target is a build switch, so the same route modules can ship with or without a server. Next.js App Router is server-first: its core abstractions — Server Components and Server Actions — assume a server runtime, so "run everything in the client in a dummy way" is not really its model.

In other words, the "it needs a server" intuition is true of Next.js but not of React Router — that is the cleanest way to keep the two frameworks apart.

When should you reach for which?

The differences above (server posture, RSC/caching, SEO, deployment) point to fairly clear use cases:

Use caseBetter fitWhy
Internal dashboard / admin panelReact RouterCRUD + auth, SEO irrelevant; can even ship as a no-server SPA
Authenticated SaaS app behind a loginReact RouterRoute-owned data/mutations; content isn't indexed, so RSC/SEO add little
Marketing site / blog / docsNext.jsSEO- and content-heavy; SSG/RSC and image/font optimization shine
E-commerce storefrontNext.jsSEO on product pages + streaming/caching for a large catalog
Existing React Router SPA growing into data needsReact RouterAdopt Data Mode route by route, then Framework Mode — no rewrite
Deploy anywhere / avoid server lock-inReact Routerssr: false or prerender need no runtime server
Vercel-first, want batteries includedNext.jsFirst-class platform integration, Incremental Static Regeneration (ISR), framework cache
Fine-grained server/client split + RSCNext.jsRSC is its core model; React Router stays route/request-centric

For a typical authenticated CRUD app with a few public pages, either works — pick by team familiarity, deployment target, and whether you actually want RSC. The rule of thumb:

Public / SEO / content-heavy → Next.js
App-like / authenticated / server-optional → React Router v7

The choice mostly collapses to two questions — does SEO/public content matter, and do you need a server at all (see Framework Mode architecture):

React Router mental model:

Next.js mental model:

Routing as a layer: the architecture around it

Step back from any one framework. In a real app, routing is one layer among several — each with a single responsibility, each handing off to the next. Seeing where it sits makes the whole picture click, and explains why "routing" decisions keep dragging in authentication, translations, and data.

Each layer has one job, and each framework draws the boundaries a little differently:

LayerResponsibilityReact RouterNext.js App RouterTanStack
Runtimewhere code runsbrowser / Node / edgeNode / edge (RSC)browser / Node
Middlewarerun before render: auth, locale, redirectmiddleware / guard loadermiddleware.tsbeforeLoad
RouterURL → matched route(s)routes + <Outlet />app/ file systemtyped route tree
Dataload / mutate / cacheloader / actionServer Components / Actionsloader + Query
Viewrender the UIReact componentsReact (RSC + client)React components

The next three subsections walk the layers that sit around the router — the ones the earlier sections kept deferring.

Authentication is a routing concern

Auth is the clearest layer that lives above the router: you decide whether a request may proceed before you render the route. The pattern is a guard that runs in the middleware/data layer and redirects when the check fails.

React Router — a guard loader that throws a redirect:

// app/routes/dashboard.tsx
import { redirect } from 'react-router';

export async function loader({ request }: { request: Request }) {
const user = await getUser(request);
if (!user) throw redirect('/login'); // bounce before the route renders
return { user };
}

Because the loader runs before the component (see Loaders), an unauthenticated visitor never sees the page. Put the guard on a layout route and every child inherits it.

Next.js — the same check in middleware.ts, before any segment renders:

// middleware.ts
import { NextResponse, type NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
const session = request.cookies.get('session');
if (!session) return NextResponse.redirect(new URL('/login', request.url));
}

export const config = { matcher: ['/dashboard/:path*'] };

This is the concrete example behind the "best for authenticated apps" claim (see Framework Mode and React Router vs Next.js): route-owned data makes the guard a few lines at the route boundary.

Internationalization lives in the URL

Translations are another cross-cutting layer the router carries, because the locale is usually part of the URL (/en/..., /fr/...) — better for SEO and shareable links than a hidden setting.

  • React Router: model the locale as a segment — /:lang/dashboard — read it with useParams() (see Navigation & URL state), and load the matching message bundle in the layout's loader. Libraries like remix-i18next wire it together.
  • Next.js App Router: use a [lang] dynamic segment (app/[lang]/...) plus middleware to detect the locale and redirect, loading a dictionary per request. (The old built-in i18n config was Pages-Router only.)
React Router Next.js App Router
/:lang/dashboard app/[lang]/dashboard/page.tsx
param :lang segment [lang]
+ i18n library + middleware locale-detect + dictionaries

Either way the router carries the locale from URL to components; the translation library just maps a locale to messages.

SEO & the document head

The last layer routing touches is the <head>: titles, meta tags, canonical links. It is worth correcting a common impression — React Router is not head-blind. Framework Mode routes export meta and links, the direct counterpart of Next's generateMetadata:

// React Router — per-route head tags
export function meta() {
return [{ title: 'Dashboard' }, { name: 'description', content: 'Your projects' }];
}

// Next.js — per-route head tags
export function generateMetadata() {
return { title: 'Dashboard', description: 'Your projects' };
}

So the SEO gap is narrower than the "Next.js for SEO" shorthand (see React Router vs Next.js) suggests. Next.js still leads on content-heavy SEO — RSC + SSG make large crawlable static sites cheap — but per-route metadata is table stakes in both.

Rendering & hydration: what runs where

Across all these models, the same few primitives decide where your code runs and when the page becomes interactive. This section names them — hydration above all — then compares every approach on one chart.

The vocabulary

TermMeaning
CSR (client-side rendering)the server sends a near-empty HTML shell + a JS bundle; the browser runs React to build the whole UI
SSR (server-side rendering)the server runs React to produce real HTML for the request, sent ready to display
SSG (static generation)the same HTML, but produced once at build time and served from a CDN
Hydrationthe client step that makes server HTML interactive: React re-runs your components in the browser, attaches event handlers, and reconnects state to the DOM the server already sent
RSC (React Server Components)components that render only on the server and ship as serialized output — they have no client JS and never hydrate
Streamingsending HTML in chunks as it's ready (via <Suspense>), so the fast parts paint before the slow parts finish

Hydration, and why "interactive" lags "visible"

Hydration is the concept the rest of the post leaned on without defining. SSR gives you HTML the user can see almost immediately — but that HTML is inert: buttons don't click and inputs don't type until React has hydrated it. Hydration downloads the JS, re-runs the components to rebuild the React tree, and wires event listeners onto the DOM the server already sent.

That creates a gap between visible and interactive, which the common metrics name:

  • TTFB (time to first byte) — how fast the server starts responding.
  • FCP (first contentful paint) — when the user first sees content.
  • TTI (time to interactive) — when the page actually responds to input.

The window between FCP and TTI is the hydration cost: a heavily-interactive SSR page can look ready yet ignore clicks for a beat. Two modern tactics shrink it — streaming (hydrate parts as they arrive, not all-or-nothing) and RSC (server components ship zero JS and skip hydration entirely, so only the interactive 'use client' islands pay the cost).

What runs where

StageCSR / SPASSRSSGRSC + streaming
Build timebundlebundlerender HTMLbundle + static shells
Server, per request— (static host)render HTML— (CDN)render server components
Client first paintafter JS runsserver HTML, instantCDN HTML, instantstreamed HTML, instant
Client interactivityfull renderhydrate allhydrate allhydrate client islands
JS to see contentyesnonono
JS to interactyesyesyesyes (islands only)

Where each approach lands

The thorough comparison — every routing path in this post, on the axes that matter:

ApproachRenders whereFirst paintTTI / hydrationSEOServer needed
React Router — Declarative / Data (SPA)clientslow (after JS)one full client renderweakno
React Router — Framework Mode, ssr:falseclientslow (after JS)one full client renderweakno
React Router — Framework Mode, prerender (SSG)buildinstanthydratestrongno
React Router — Framework Mode, SSRserverinstanthydrate (streamed)strongyes
Next.js App Router (RSC + SSR)server (+build)instanthydrate client islands onlystrongyes (effectively)
Next.js App Router, SSG / prerenderbuildinstanthydrate islandsstrongno (static paths)
TanStack Router (client)clientslow (after JS)one full client renderweakno
TanStack Start (SSR)serverinstanthydrate (streamed)strongyes

Reading the table: the server-rendered rows trade a server (and a hydration step) for instant, crawlable first paint; the client rows need no server but pay a blank-screen-until-JS cost and weak SEO; RSC is the one model that renders on the server without hydrating everything, which is why its interactive cost can be the lowest — at the price of the most complex model (the Next.js App Router's caching and server/client boundary).

The rule of thumb stays the one from React Router vs Next.js: if first paint, SEO, and crawlability matter, render on the server (SSR / SSG / RSC); if you just need an app behind a login, a client SPA is simpler and needs no server.

Developer experience

Routing choices are also DX choices. Five things shape day-to-day work more than any feature table: type safety, testing, prefetching, accessibility, and tooling.

Type-safe routes

A wrong route path or param is a class of bug a router can catch at compile time.

  • React Router Framework Mode generates per-route types: run react-router typegen (the Vite plugin does it automatically) and import Route from ./+types/<route> for typed params, loaderData, and actionData (see Framework Mode and the CRUD app). It also ships a typed href() helper for links.
  • Next.js has typed routes via the typedRoutes config, which checks <Link href> against your app/ tree.
  • TanStack Router (see TanStack) is the benchmark: types are inferred end-to-end with no codegen step, including validated search params.
// React Router Framework Mode — typed params + loader data, generated per route
import type { Route } from './+types/user';

export async function loader({ params }: Route.LoaderArgs) {
return getUser(params.userId); // params.userId is typed
}

export default function User({ loaderData }: Route.ComponentProps) {
return <h1>{loaderData.name}</h1>; // loaderData is typed
}

Testing routed code

Components that call useLoaderData, useNavigation, or <Form> need a router in the test.

  • React Router: createMemoryRouter renders a route tree at a chosen URL with no browser; or createRoutesStub mounts a component with stubbed loaders/actions — ideal for unit tests.
  • Next.js: Server Components and Server Actions are harder to unit-test in isolation (they assume a server runtime), so testing leans more on end-to-end tools like Playwright.
// React Router — drive a component through a real route at a chosen URL
import { createMemoryRouter, RouterProvider } from 'react-router';

const router = createMemoryRouter(
[{ path: '/users/:id', Component: UserPage, loader: () => ({ name: 'Ada' }) }],
{ initialEntries: ['/users/1'] },
);
render(<RouterProvider router={router} />);

Prefetching

Both routers can fetch a route's code and data before you click, so navigation feels instant.

FrameworkHow prefetch works
React Router<Link prefetch="intent"> (on hover/focus), or "render" / "viewport"
Next.js<Link> prefetches automatically when it enters the viewport (in prod)

Accessibility & scroll restoration

Client-side routing replaces the browser's built-in navigation, so two things it used to do for free become your job:

  • Scroll position — React Router's <ScrollRestoration /> restores it per location; Next.js resets scroll automatically on navigation.
  • Focus & announcements — a soft navigation doesn't move focus or tell a screen reader the page changed. Move focus to the new <main> / heading and announce the route change via an ARIA live region. This is the most-skipped part of "modern routing" — worth building into your root layout once.

Setup & devtools

ConcernReact RouterNext.jsTanStack
Scaffoldnpx create-react-routernpx create-next-appTanStack Router/Start starters
Devtoolsnone officialdev overlay + error UITanStack Router Devtools
Build/HMRVite pluginNext compiler (Turbopack)Vite

The DX headline: TanStack leads on type safety and devtools, Next.js on integrated tooling and platform polish, React Router on a lightweight, Vite-native setup you can adopt incrementally.

Migration paths

You rarely start from scratch. The three common moves:

From → ToEffortApproach
React Router v6 → v7lowLargely drop-in; import from react-router; opt into modes when ready
Next Pages Router → App Routerincrementalapp/ and pages/ coexist; move routes one at a time
SPA → frameworkincrementalAdopt React Router Data Mode route by route, then Framework Mode
  • React Router v6 → v7 is intentionally gentle: v7 is mostly a renamed, repackaged v6 (react-router instead of react-router-dom), so most apps upgrade with minimal changes and then adopt modes gradually (see the three modes).
  • Next Pages → App Router runs both routers side by side: the app/ and pages/ directories coexist, so you migrate route by route rather than in one cutover.
  • SPA → framework is the path this post has traced all along — start declarative, add Data Mode where you need loaders/actions, and move to Framework Mode for SSR and typed routes when the app warrants it.

The throughline: every modern option is designed for incremental adoption, so "which router" is rarely a one-way door.

Putting it all together: a small CRUD app

The advice everyone gives is right: build one tiny CRUD app with nested routes, and the model clicks. Here is that app — a Projects manager in Framework Mode that exercises every concept from the declarative example through Framework Mode at once: nested layout routes, a dynamic param, loader, action, validation, redirect, <Form>, pending UI, an isolated useFetcher delete, and a route ErrorBoundary.

URLRouteConcepts exercised
/projectslayout + index listlayout route, loader, <Outlet />, pending bar
/projects/newcreate formaction, validation, redirect
/projects/:projectIddetaildynamic param, loader, 404, useFetcher delete
/projects/:projectId/editedit formparam + loader + action + redirect

The route tree is one nested layout with four children:

// app/routes.ts
import { type RouteConfig, index, route } from '@react-router/dev/routes';

export default [
route('projects', './routes/projects.tsx', [
index('./routes/projects-list.tsx'),
route('new', './routes/project-new.tsx'),
route(':projectId', './routes/project-detail.tsx'),
route(':projectId/edit', './routes/project-edit.tsx'),
]),
] satisfies RouteConfig;

The layout route renders the shared chrome, a global pending indicator from useNavigation (see useNavigation), the <Outlet /> for its children, and an ErrorBoundary that catches anything a child route throws — including the detail route's 404:

// app/routes/projects.tsx
import {
Link,
Outlet,
useNavigation,
useRouteError,
isRouteErrorResponse,
} from 'react-router';

export default function ProjectsLayout() {
const navigation = useNavigation();
const busy = navigation.state !== 'idle';

return (
<section>
<header>
<h1>Projects</h1>
<Link to="/projects/new">New project</Link>
{busy && <span role="status">Working…</span>}
</header>

<Outlet />
</section>
);
}

export function ErrorBoundary() {
const error = useRouteError();

if (isRouteErrorResponse(error)) {
return <p>{error.status}{error.statusText}</p>;
}

return <p>Something went wrong.</p>;
}

The list is the index route: a loader reads the data, the component renders it with typed loaderData (see Loaders):

// app/routes/projects-list.tsx
import { Link } from 'react-router';
import type { Route } from './+types/projects-list';

export async function loader() {
return { projects: await db.projects.all() };
}

export default function ProjectsList({ loaderData }: Route.ComponentProps) {
return (
<ul>
{loaderData.projects.map((project) => (
<li key={project.id}>
<Link to={`/projects/${project.id}`}>{project.name}</Link>
</li>
))}
</ul>
);
}

The create route is the integration centerpiece. Its action validates the FormData; on invalid input it returns field errors and stays on the page, and on success it redirects to the new detail page. The component reads actionData?.errors and uses useNavigation for the button's pending state:

// app/routes/project-new.tsx
import { Form, redirect, useNavigation } from 'react-router';
import type { Route } from './+types/project-new';

export async function action({ request }: Route.ActionArgs) {
const form = await request.formData();
const name = String(form.get('name') ?? '').trim();

// validation: return errors and stay on the form
const errors: Record<string, string> = {};
if (!name) errors.name = 'Name is required';
if (Object.keys(errors).length > 0) {
return { errors };
}

// success: create, then redirect to the detail page
const project = await db.projects.create({ name });
return redirect(`/projects/${project.id}`);
}

export default function ProjectNew({ actionData }: Route.ComponentProps) {
const navigation = useNavigation();
const saving = navigation.state === 'submitting';

return (
<Form method="post">
<label>
Name
<input name="name" />
</label>

{actionData?.errors?.name && (
<p role="alert">{actionData.errors.name}</p>
)}

<button disabled={saving}>{saving ? 'Saving…' : 'Create'}</button>
</Form>
);
}

The detail route reads its project from the dynamic param and throws a 404 Response when it is missing — which the layout's ErrorBoundary renders. Delete runs through a useFetcher (see useFetcher), so its pending state is isolated to the button:

// app/routes/project-detail.tsx
import { Link, redirect, useFetcher } from 'react-router';
import type { Route } from './+types/project-detail';

export async function loader({ params }: Route.LoaderArgs) {
const project = await db.projects.find(params.projectId);
if (!project) {
throw new Response('Project not found', { status: 404 });
}
return { project };
}

export async function action({ params }: Route.ActionArgs) {
await db.projects.remove(params.projectId);
return redirect('/projects');
}

export default function ProjectDetail({ loaderData }: Route.ComponentProps) {
const { project } = loaderData;
const fetcher = useFetcher();
const deleting = fetcher.state !== 'idle';

return (
<article>
<h2>{project.name}</h2>
<Link to={`/projects/${project.id}/edit`}>Edit</Link>

<fetcher.Form method="post">
<button disabled={deleting}>{deleting ? 'Deleting…' : 'Delete'}</button>
</fetcher.Form>
</article>
);
}

The edit route is just the previous two combined: a loader({ params }) to pre-fill the form like the detail route, and a validating action that redirects on success like the create route. No new concept — which is the point.

The create path shows the whole loop closing on itself:

Every "area" of modern React Router shows up once in this one app:

AreaIn this app
Routingnested projects layout + :projectId dynamic param
Data loadingloader in the list and detail routes
Forms<Form> for create/edit, fetcher.Form for delete
Navigation stateuseNavigation pending bar, isolated fetcher.state
Error handlinglayout ErrorBoundary + thrown 404 Response
Framework Modefile-based routes.ts, typed Route.* args

Final summary

React routing evolved like this:

URL → component
URL → nested component tree
URL → route with data loading
URL → route with data + mutations + errors
URL → framework boundary

The most important concept is ownership.

In React Router v7 Framework Mode:

The route owns data, mutations, errors, and pending state.

In Next.js App Router:

The server-rendered component tree owns data, rendering, and caching.

That is the current big difference.

Glossary

A quick reference for the acronyms and routing terms used throughout this post.

Rendering & delivery acronyms

TermStands forIn one line
SPAsingle-page applicationOne HTML document; the client router swaps views in place without full reloads.
MPAmulti-page applicationThe classic model: every navigation loads a fresh HTML document from the server.
CSRclient-side renderingThe browser builds the UI from a JS bundle after loading a near-empty shell.
SSRserver-side renderingThe server renders real HTML per request; the client then hydrates it.
SSGstatic site (static) generationHTML rendered once at build time and served from a CDN.
ISRIncremental Static RegenerationNext.js feature that rebuilds static pages after deploy, on a schedule/on demand.
RSCReact Server ComponentsComponents that render only on the server and ship no client JS — they never hydrate.
TTFBtime to first byteHow quickly the server starts responding.
FCPfirst contentful paintWhen the user first sees content.
TTItime to interactiveWhen the page actually responds to input.
SEOsearch engine optimizationHow well crawlers can read and index a page's content.
CDNcontent delivery networkEdge servers that cache and serve static assets/HTML close to the user.
HMRhot module replacementDev-server feature that swaps changed modules without a full reload.
DXdeveloper experienceHow pleasant and productive the day-to-day tooling feels.
RPCremote procedure callCalling a server function as if it were local (TanStack Start's server functions).
CRUDcreate, read, update, deleteThe four basic data operations a typical app performs.

Routing & data concepts

TermWhat it means
Route moduleA single file exporting a route's UI plus its loader, action, and ErrorBoundary.
LoaderA function that reads a route's data before the route renders.
ActionThe route handler that runs on a non-GET request to perform a write.
MutationThe actual data change (createUser, deleteItem, …) performed inside an action.
OutletA router-controlled slot where a parent route renders its matched child route.
Layout routeA parent route that renders shared chrome plus an <Outlet /> for its children.
Nested routesRoutes composed as a tree, so one URL maps to a stack of nested layouts.
RevalidationRe-running the active loaders after an action so the UI reflects the change.
HydrationThe client step that turns server-rendered HTML into a live, interactive React app.
StreamingSending HTML in chunks (via <Suspense>) so fast parts paint before slow parts finish.
Optimistic UIRendering the expected result immediately, before the server confirms it.
FetcheruseFetcher — an isolated submit/load with its own pending state that does not navigate.
Progressive enhancementA <Form> that works before JS loads and gets enhanced once it does.
History APIBrowser pushState / replaceState / popstate — change the URL with no request or reload.
Server Component (Next.js)An async component that renders only on the server and ships no client JS.
Client Component (Next.js)A 'use client' subtree that hydrates and runs in the browser.
Server Action (Next.js)A 'use server' function passed to a <form> to handle a mutation on the server.
Declarative / Data / Framework ModeReact Router's three modes: URL→component; + loaders/actions; + route modules, SSR, typed routes.
PrefetchingFetching a route's code and/or data before the user navigates, so the click feels instant.
MicrofrontendAn independently deployable slice of an app, typically owned by a separate team.
Module FederationA bundler technique for loading remote modules/routes at runtime across separate builds.

References