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.

ExportIn the client bundle
default componentYes
ErrorBoundary, HydrateFallbackYes
links, meta, shouldRevalidate, handleYes
clientLoader, clientAction, clientMiddlewareYes
loader, action, middlewareNo, stripped
headersNo, 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.

SuffixMeaning
*.server.tsServer only. Importing it from client code fails the build
*.client.tsClient 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 valueFetches when
noneDefault, on click only
intentOn hover or focus. The usual choice
renderAs soon as the link renders
viewportWhen 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.