React Router Cheatsheet
Code Splitting and Prefetching
Use this React Router reference while you build software engineering projects, review code, or refresh the syntax you reach for most.
It Is Already Done
In Framework mode the Vite plugin splits your app for you. Every module
referenced from app/routes.ts becomes a bundler entry point, and because
those entries are tied to URL segments the router knows from the URL alone
which chunks are needed and which are not.
// app/routes.ts export default [ route("about", "routes/about.tsx"), // its own chunk route("contact", "routes/contact.tsx"), // its own chunk ] satisfies RouteConfig;
Visiting /about loads the about chunk and never touches contact. You
do not write React.lazy for a route, and wrapping one in React.lazy
just adds a second waterfall on top of the router's own.
Server Code Is Stripped
Server only route exports are removed from the browser build entirely, so
a database import inside a loader never ships to the client.
| Export | In the client bundle |
|---|---|
default component | Yes |
ErrorBoundary, HydrateFallback | Yes |
links, meta, shouldRevalidate, handle | Yes |
clientLoader, clientAction, clientMiddleware | Yes |
loader, action, middleware | No, stripped |
headers | No, stripped |
// only Component survives in the browser build export async function loader() { const rows = await db.query("select * from posts"); // server only return { rows }; } export default function Posts({ loaderData }: Route.ComponentProps) { return <List rows={loaderData.rows} />; }
Module Suffixes
When the stripping is not enough, or a module is imported from both sides, the suffixes make the boundary explicit and turn a leak into a build error.
| Suffix | Meaning |
|---|---|
*.server.ts | Server only. Importing it from client code fails the build |
*.client.ts | Client only. Empty on the server |
// app/db.server.ts, safe to import from any loader import { drizzle } from "drizzle-orm/node-postgres"; export const db = drizzle(process.env.DATABASE_URL!);
Put session storage, secrets, and any database client behind
.server.ts. That one rule prevents most accidental secret leaks.
Route Module Splitting
Client route exports (clientLoader, clientAction, clientMiddleware,
HydrateFallback) are split into their own chunks so they can be fetched
and run while the component code is still downloading.
// react-router.config.ts import type { Config } from "@react-router/dev/config"; export default { splitRouteModules: true, // default in v8 // false keep each route module in one chunk // "enforce" fail the build if a route cannot be split } satisfies Config;
This was future.v8_splitRouteModules in v7. In v8 it is a top level
config option and it is on by default. "enforce" fails the build when a
route shares code between a client export and its component, which is the
setting to use if you care about the last few kilobytes.
Prefetching
Splitting makes chunks. Prefetching makes them arrive before the click.
prefetch value | Fetches when |
|---|---|
none | Default, on click only |
intent | On hover or focus. The usual choice |
render | As soon as the link renders |
viewport | When the link scrolls into view. Good for mobile lists |
<Link to="/about" prefetch="intent">About</Link> <Form action="/search" method="get" discover="none" />
Prefetching pulls the route's module, its data, and its CSS at once, so an
intent link usually means an instant transition. It renders a
<link rel="prefetch"> right after the anchor, which breaks CSS selectors
like nav a:last-child.
Lazy Route Discovery
Framework mode does not ship the entire route manifest up front. Routes
are discovered lazily through a /__manifest request as links render,
which keeps the initial payload flat as the app grows.
// react-router.config.ts export default { routeDiscovery: { mode: "lazy", manifestPath: "/__manifest" }, // routeDiscovery: { mode: "initial" } ship the full manifest instead } satisfies Config;
Set discover="none" on a Link or Form to skip eager discovery for
that one link.
Data and Declarative Modes
There is no bundler integration outside Framework mode, so splitting is manual.
// Data mode: lazy route objects const router = createBrowserRouter([ { path: "dashboard", lazy: () => import("./routes/dashboard"), // exports Component, loader }, { path: "reports", lazy: { Component: async () => (await import("./reports")).Reports, loader: async () => (await import("./reports")).loader, }, }, ]);
// Declarative mode: plain React.lazy const Settings = React.lazy(() => import("./Settings")); <Route path="settings" element={ <Suspense fallback={<Spinner />}> <Settings /> </Suspense> } />
React.lazy is still right in Framework mode for a genuinely heavy
component inside a route, a chart library or an editor, just not for the
route itself.