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:
- Declarative Mode
- Data Mode
- Framework Mode
See React Router docs — Picking a Mode.
The short history
| Period | Main idea | Key features it introduced | Technologies |
|---|---|---|---|
| Pre-2020 | Client-side routing matures | client <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 |
| 2021 | Full-stack route modules | loader (read) + action (write), <Form> + progressive enhancement, automatic revalidation, web-standard Request/Response, nested data with SSR + streaming | Remix |
| 2022 | Data routers enter React Router | Remix'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 |
| 2022 | Server-first routing becomes mainstream | React Server Components, nested layout.tsx, loading.tsx streaming, error.tsx, server-first data fetching (Server Actions soon after) | Next.js 13 App Router |
| 2024 | Remix merges back into React Router | route modules as a framework, typed routes, the Vite plugin, SSR/SSG/SPA targets, per-route code-splitting | React Router v7 Framework Mode |
| 2025+ | Two main models dominate | convergence — file-based routing, SSR + streaming, typed routes, and server mutations on both sides | React 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 idea | What it added |
|---|---|
| Route modules | one file per route, exporting its UI + data loader + mutation action + error boundary together |
| Web standards | build on the browser's own Request / Response / FormData / fetch instead of framework-specific request/response APIs (see below) |
<Form> → action | form submissions are handled by the route's action, and work before JS loads (progressive enhancement) |
| Automatic revalidation | after a successful action, the route's loaders re-run so the UI stays fresh |
| Server rendering & streaming | SSR and streaming are first-class concerns, not add-ons |
| A build step | a 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 you — Request, 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 primitive | What it does |
|---|---|
<a href> / <form> | declare a navigation; activating one triggers a full document load |
window.location | read or set the current URL (setting it navigates) |
History API — pushState, replaceState, popstate | change the URL without a request or reload, and observe Back/Forward |
| history stack + scroll | Back/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:
- intercepts the link click (calls
preventDefaultso the browser skips its full load), - pushes the new URL with
history.pushState, - matches that URL to a component and renders it — no request, no reload, React state intact,
- listens for
popstateso 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 + pushState | navigation happens with no full reload |
<Routes> / <Route> | URL-to-document matching | a URL renders a component instead of a fresh document |
<Form> | <form> + submit intercept | a 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:
| Router | History backend | Use it for |
|---|---|---|
BrowserRouter | the History API (clean URLs) | normal web apps |
HashRouter | the URL #fragment | static hosts that can't rewrite unknown paths to index.html |
MemoryRouter | an 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:
| URL | Rendered layout chain |
|---|---|
/ | RootLayout → HomePage |
/dashboard | RootLayout → DashboardLayout → DashboardHomePage |
/dashboard/orders | RootLayout → DashboardLayout → OrdersPage |
/dashboard/settings | RootLayout → 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:
| Topic | Declarative Mode | Data Mode | Framework Mode |
|---|---|---|---|
| Setup | <BrowserRouter> | createBrowserRouter | routes.ts + Vite plugin |
| Route definition | JSX | config objects | files / route modules |
| Data loading | inside components | loader() | loader() |
| Mutations | manual | action() | action() |
| Pending UI | manual state | useNavigation() | useNavigation() |
| SSR / splitting / types | — | — | built in |
| Best for | simple SPAs | SPAs with data | full-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.
| Event | Loader runs? |
|---|---|
| First navigation to the route | Yes |
| Component re-render (state change) | No — useLoaderData reads stored data |
| Navigate away and back | Yes (re-runs) |
| After a successful action | Yes (revalidates) |
shouldRevalidate returns false | No |
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'sonClick, a<select>'sonChange(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:
| Concern | Manual onSubmit | Route action |
|---|---|---|
| Form data | controlled useState per field | FormData from <Form> |
| Pending state | manual useState | useNavigation() |
| Errors | try/catch + local state | route ErrorBoundary |
| Refresh after mutate | manual re-fetch / invalidate | automatic loader revalidation |
| Works without JS | no | yes, 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/:idalready identifies what it's about, so the route is the endpoint: a GET runs theloader(read), a POST runs theaction(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
fetchinside 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:
| State | Meaning |
|---|---|
idle | Nothing pending |
loading | Navigation or loader revalidation is happening |
submitting | A 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:
| Concern | Manual useState | useNavigation() |
|---|---|---|
| Who owns the in-flight state | your component | the router |
| Sees router navigations/revalidation | no | yes |
| Readable from layouts / a global bar | no (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 intentionalResponseyou threw (a 404/401 — part of your control flow) from an unexpectedError(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
errorElementon the route object instead of exportingErrorBoundary.
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:
| Concern | React Router | Next.js App Router |
|---|---|---|
| Catch errors in a subtree | ErrorBoundary export / errorElement | error.tsx (a 'use client' boundary) |
| Retry after an error | re-navigate / revalidate | the reset() prop passed to error.tsx |
| 404 / not-found | throw new Response(null,{status:404}) | not-found.tsx + notFound() |
| Catch errors in the root layout | root route ErrorBoundary | global-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.
Navigation & URL state
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 ofredirect()from a loader or action).
Reading the URL: params and search
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';
// ...
}
| Need | React Router | Next.js (next/navigation) |
|---|---|---|
| Programmatic navigation | useNavigate() | useRouter() → push / replace |
| Active-aware link | <NavLink> | <Link> + usePathname() |
| Declarative redirect | <Navigate> / redirect() | redirect() (next/navigation) |
| Read path param | useParams() | useParams() (or params prop) |
| Read current path | useLocation() | usePathname() |
| Read / write query string | useSearchParams() (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.
| Approach | Whole route waits? | How the slow part renders |
|---|---|---|
Plain await in loader | yes | only after everything resolves |
Return a promise + Await | no | <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):
| Export | Role | Runs on |
|---|---|---|
default | The route component | Server + client |
loader | Read data before rendering | Server |
clientLoader | Read data in the browser | Client |
action / clientAction | Handle mutations | Server / client |
ErrorBoundary | Error UI when any export throws | Server + client |
HydrateFallback | UI while client data loads on hydrate | Client |
headers, meta, links | HTTP headers and <head> content | Server |
middleware / clientMiddleware | Run around loaders and actions | Server / client |
handle, shouldRevalidate | Route 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 CRUD | It's a small SPA where the Vite-plugin toolchain is pure overhead |
| You want one enforced way to load / mutate / handle errors | The team already standardizes on its own data + routing stack |
| You'll use SSR, SSG, typed routes, or per-route code-splitting | You can't commit to a build-time, all-in switch (see the three modes) |
| Consistency and onboarding matter more than per-route freedom | You'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;
| Strategy | ssr | prerender | Result |
|---|---|---|---|
| Full SSR | true | — | Every request rendered by the server |
| SSG + server | true | paths | Static HTML for chosen paths, server for rest |
| SPA mode | false | — | One index.html, everything client-side |
| SSG + SPA fallback | false | paths | Static 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
| File | Role | React Router analog |
|---|---|---|
page.tsx | the route's UI | route module default export |
layout.tsx | persistent nested shell | layout route + <Outlet /> |
loading.tsx | Suspense fallback while the page awaits | useNavigation pending UI |
error.tsx | error boundary (must be a Client Comp.) | route ErrorBoundary |
not-found.tsx | UI 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:
| Concern | React Router | Next.js App Router |
|---|---|---|
| Where it lives | route-level action export | a 'use server' function passed to a <form> |
| Revalidation | automatic loader re-run | explicit revalidatePath / revalidateTag |
| Pending state | useNavigation / useFetcher | useFormStatus / 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:
| Cache | Stores | Where | Next.js 15 default |
|---|---|---|---|
| Request Memoization | identical fetch() within one render | server, per request | on |
| Data Cache | fetch results across requests | server | off — opt in with cache: 'force-cache' or next: { revalidate } |
| Full Route Cache | rendered RSC/HTML of static routes | server / build | static by default; a dynamic API opts a route out |
| Router Cache | already-visited route segments | browser | short-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
| Feature | What it's for | React Router analog |
|---|---|---|
middleware.ts | run before render — auth, locale, redirects | a guard loader / middleware |
generateStaticParams | which dynamic params to prerender at build (SSG) | prerender config paths |
generateMetadata | per-route <head> / SEO tags | meta export (see Routing as a layer) |
Route Handlers route.ts | HTTP endpoints (GET/POST…) in the app/ tree | action + 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
useSearchParamsfrom 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
| Axis | React Router v7 | Next.js App Router | TanStack Router / Start |
|---|---|---|---|
| Core bet | route/request model | RSC component tree | end-to-end type safety |
| Route definition | JSX / config / files | file system | code or file-based (typed) |
| Search params | useSearchParams (untyped) | useSearchParams (untyped) | typed + validated |
| Data loading | loader | Server Components | loader (+ TanStack Query) |
| Full-stack layer | Framework Mode | App Router (built in) | TanStack Start (newer) |
| Server required | optional | effectively required | optional (Start adds SSR) |
| Maturity | very mature | very mature | router 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 Router | Next.js App Router |
|---|---|
loader | async Server Component |
action | Server Action |
ErrorBoundary | error.tsx |
useNavigation | loading.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
loaderis 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
asyncServer 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.
| Topic | React Router v7 Framework Mode | Next.js App Router |
|---|---|---|
| Core mental model | Route/request-centric | Server Component tree-centric |
| Data loading | loader() | async Server Components |
| Mutations | action() + forms/fetchers | Server Actions / Route Handlers |
| Main boundary | Route module | Server/client component boundary |
| Pending UI | useNavigation, useFetcher | loading.tsx, Suspense |
| Errors | Route ErrorBoundary | error.tsx |
| Layouts | Route hierarchy + <Outlet /> | Nested layout.tsx files |
| Caching | Simpler route/request model | More powerful, more complex framework cache |
| Best fit | CRUD apps, dashboards, authenticated apps | SEO-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:
| Framework | First load | Navigation handled by | Server hit on navigation? |
|---|---|---|---|
| React Router — Declarative / Data (SPA) | static shell + JS bundle | client router | only if the route has a loader |
| React Router — Framework Mode (SSR) | server-rendered HTML | client router | yes — to fetch loader data |
| Next.js — App Router | server-rendered RSC payload | client router | yes — 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.
| Axis | React Router v7 Framework Mode | Next.js App Router |
|---|---|---|
| Server requirement | Optional — SSR is default, but SPA/SSG need no server | Effectively required — RSC assumes a server runtime |
| Where data runs | loader/action on server, or clientLoader/clientAction in the browser | async Server Components + Server Actions run on the server |
| Deploy target | Any Node/edge host via adapters, or static/SPA to a CDN | Node/edge runtime; first-class on Vercel |
| Pure-client option | Yes (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 case | Better fit | Why |
|---|---|---|
| Internal dashboard / admin panel | React Router | CRUD + auth, SEO irrelevant; can even ship as a no-server SPA |
| Authenticated SaaS app behind a login | React Router | Route-owned data/mutations; content isn't indexed, so RSC/SEO add little |
| Marketing site / blog / docs | Next.js | SEO- and content-heavy; SSG/RSC and image/font optimization shine |
| E-commerce storefront | Next.js | SEO on product pages + streaming/caching for a large catalog |
| Existing React Router SPA growing into data needs | React Router | Adopt Data Mode route by route, then Framework Mode — no rewrite |
| Deploy anywhere / avoid server lock-in | React Router | ssr: false or prerender need no runtime server |
| Vercel-first, want batteries included | Next.js | First-class platform integration, Incremental Static Regeneration (ISR), framework cache |
| Fine-grained server/client split + RSC | Next.js | RSC 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:
| Layer | Responsibility | React Router | Next.js App Router | TanStack |
|---|---|---|---|---|
| Runtime | where code runs | browser / Node / edge | Node / edge (RSC) | browser / Node |
| Middleware | run before render: auth, locale, redirect | middleware / guard loader | middleware.ts | beforeLoad |
| Router | URL → matched route(s) | routes + <Outlet /> | app/ file system | typed route tree |
| Data | load / mutate / cache | loader / action | Server Components / Actions | loader + Query |
| View | render the UI | React components | React (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 withuseParams()(see Navigation & URL state), and load the matching message bundle in the layout'sloader. Libraries likeremix-i18nextwire it together. - Next.js App Router: use a
[lang]dynamic segment (app/[lang]/...) plusmiddlewareto detect the locale and redirect, loading a dictionary per request. (The old built-ini18nconfig 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
| Term | Meaning |
|---|---|
| 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 |
| Hydration | the 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 |
| Streaming | sending 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
| Stage | CSR / SPA | SSR | SSG | RSC + streaming |
|---|---|---|---|---|
| Build time | bundle | bundle | render HTML | bundle + static shells |
| Server, per request | — (static host) | render HTML | — (CDN) | render server components |
| Client first paint | after JS runs | server HTML, instant | CDN HTML, instant | streamed HTML, instant |
| Client interactivity | full render | hydrate all | hydrate all | hydrate client islands |
| JS to see content | yes | no | no | no |
| JS to interact | yes | yes | yes | yes (islands only) |
Where each approach lands
The thorough comparison — every routing path in this post, on the axes that matter:
| Approach | Renders where | First paint | TTI / hydration | SEO | Server needed |
|---|---|---|---|---|---|
| React Router — Declarative / Data (SPA) | client | slow (after JS) | one full client render | weak | no |
React Router — Framework Mode, ssr:false | client | slow (after JS) | one full client render | weak | no |
| React Router — Framework Mode, prerender (SSG) | build | instant | hydrate | strong | no |
| React Router — Framework Mode, SSR | server | instant | hydrate (streamed) | strong | yes |
| Next.js App Router (RSC + SSR) | server (+build) | instant | hydrate client islands only | strong | yes (effectively) |
| Next.js App Router, SSG / prerender | build | instant | hydrate islands | strong | no (static paths) |
| TanStack Router (client) | client | slow (after JS) | one full client render | weak | no |
| TanStack Start (SSR) | server | instant | hydrate (streamed) | strong | yes |
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 importRoutefrom./+types/<route>for typedparams,loaderData, andactionData(see Framework Mode and the CRUD app). It also ships a typedhref()helper for links. - Next.js has typed routes via the
typedRoutesconfig, which checks<Link href>against yourapp/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:
createMemoryRouterrenders a route tree at a chosen URL with no browser; orcreateRoutesStubmounts 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.
| Framework | How 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
| Concern | React Router | Next.js | TanStack |
|---|---|---|---|
| Scaffold | npx create-react-router | npx create-next-app | TanStack Router/Start starters |
| Devtools | none official | dev overlay + error UI | TanStack Router Devtools |
| Build/HMR | Vite plugin | Next 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 → To | Effort | Approach |
|---|---|---|
| React Router v6 → v7 | low | Largely drop-in; import from react-router; opt into modes when ready |
| Next Pages Router → App Router | incremental | app/ and pages/ coexist; move routes one at a time |
| SPA → framework | incremental | Adopt 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-routerinstead ofreact-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/andpages/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.
| URL | Route | Concepts exercised |
|---|---|---|
/projects | layout + index list | layout route, loader, <Outlet />, pending bar |
/projects/new | create form | action, validation, redirect |
/projects/:projectId | detail | dynamic param, loader, 404, useFetcher delete |
/projects/:projectId/edit | edit form | param + 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:
| Area | In this app |
|---|---|
| Routing | nested projects layout + :projectId dynamic param |
| Data loading | loader in the list and detail routes |
| Forms | <Form> for create/edit, fetcher.Form for delete |
| Navigation state | useNavigation pending bar, isolated fetcher.state |
| Error handling | layout ErrorBoundary + thrown 404 Response |
| Framework Mode | file-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
| Term | Stands for | In one line |
|---|---|---|
| SPA | single-page application | One HTML document; the client router swaps views in place without full reloads. |
| MPA | multi-page application | The classic model: every navigation loads a fresh HTML document from the server. |
| CSR | client-side rendering | The browser builds the UI from a JS bundle after loading a near-empty shell. |
| SSR | server-side rendering | The server renders real HTML per request; the client then hydrates it. |
| SSG | static site (static) generation | HTML rendered once at build time and served from a CDN. |
| ISR | Incremental Static Regeneration | Next.js feature that rebuilds static pages after deploy, on a schedule/on demand. |
| RSC | React Server Components | Components that render only on the server and ship no client JS — they never hydrate. |
| TTFB | time to first byte | How quickly 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. |
| SEO | search engine optimization | How well crawlers can read and index a page's content. |
| CDN | content delivery network | Edge servers that cache and serve static assets/HTML close to the user. |
| HMR | hot module replacement | Dev-server feature that swaps changed modules without a full reload. |
| DX | developer experience | How pleasant and productive the day-to-day tooling feels. |
| RPC | remote procedure call | Calling a server function as if it were local (TanStack Start's server functions). |
| CRUD | create, read, update, delete | The four basic data operations a typical app performs. |
Routing & data concepts
| Term | What it means |
|---|---|
| Route module | A single file exporting a route's UI plus its loader, action, and ErrorBoundary. |
| Loader | A function that reads a route's data before the route renders. |
| Action | The route handler that runs on a non-GET request to perform a write. |
| Mutation | The actual data change (createUser, deleteItem, …) performed inside an action. |
| Outlet | A router-controlled slot where a parent route renders its matched child route. |
| Layout route | A parent route that renders shared chrome plus an <Outlet /> for its children. |
| Nested routes | Routes composed as a tree, so one URL maps to a stack of nested layouts. |
| Revalidation | Re-running the active loaders after an action so the UI reflects the change. |
| Hydration | The client step that turns server-rendered HTML into a live, interactive React app. |
| Streaming | Sending HTML in chunks (via <Suspense>) so fast parts paint before slow parts finish. |
| Optimistic UI | Rendering the expected result immediately, before the server confirms it. |
| Fetcher | useFetcher — an isolated submit/load with its own pending state that does not navigate. |
| Progressive enhancement | A <Form> that works before JS loads and gets enhanced once it does. |
| History API | Browser 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 Mode | React Router's three modes: URL→component; + loaders/actions; + route modules, SSR, typed routes. |
| Prefetching | Fetching a route's code and/or data before the user navigates, so the click feels instant. |
| Microfrontend | An independently deployable slice of an app, typically owned by a separate team. |
| Module Federation | A bundler technique for loading remote modules/routes at runtime across separate builds. |
References
-
React Router docs — Picking a Mode Explains the three React Router modes: Declarative Mode, Data Mode, and Framework Mode.
-
React Router docs — Framework Mode routing Describes how routes are defined in Framework Mode using URL patterns and route modules.
-
React Router docs — Route Module Lists every named export a route module can implement (loader, action, ErrorBoundary, meta, …) and when each one runs.
-
React Router docs — Pre-rendering Documents the
ssrandprerenderconfig options and the hybrid rendering strategies they enable. -
React Router docs — Outlet Explains how nested route content is rendered inside parent route layouts.
-
React Router docs — loader Documents route loaders as the mechanism for loading route data.
-
React Router docs — useLoaderData Defines how route components read data returned by their matched route loader.
-
React Router docs — action Documents route actions for handling mutations, usually from forms or submissions.
-
React Router docs — shouldRevalidate Lets a route opt out of re-running its loader when its data has not changed.
-
React Router docs — clientLoader Loads route data in the browser, the hook for client-side caching of loader data.
-
React Router docs — useNavigation Documents how to read the router’s current navigation or submission state.
-
React Router docs — useFetcher Documents isolated submissions and data loads that have their own pending state and do not navigate.
-
Remix blog — Merging Remix and React Router Explains why Remix and React Router were merged and why React Router v7 continues the former Remix model.
-
Remix blog — React Router v7 Announces React Router v7 and frames it as bringing Remix features back into React Router.
-
Next.js docs — App Router Documents the Next.js App Router model, including layouts, Server Components, routing, and data fetching.
-
Next.js docs — Server and Client Components Explains the server/client component split that is central to the Next.js App Router mental model.
-
Next.js docs — Updating Data Explains how Next.js handles mutations using Server Actions.
