This guide covers the complete architecture, configuration surface, deployment topology, security posture, and internal module reference for React Router v7 — a multi-strategy router for React supporting SPA, SSR, and static prerendering modes. It targets administrators deploying React Router applications in production, developers extending the framework or building custom adapters, and architecture reviewers evaluating the project.
For end-user installation, routing patterns, and everyday usage, see the User Guide.
1. Architecture Overview
1.1 High-Level System Design
React Router is a monorepo containing 11 packages that together form a full-stack React framework. The architecture separates concerns into four distinct layers:
| Layer | Packages | Responsibility |
|---|---|---|
| Core Runtime | react-router |
Route matching, data loading, navigation state, history management, React context distribution |
| Build Tooling | @react-router/dev |
Vite plugin, CLI, config resolution, code generation, type generation, HMR |
| Server Adapters | @react-router/express, @react-router/node, @react-router/cloudflare, @react-router/architect, @react-router/serve |
Platform-specific request/response translation, session storage |
| Routing Conventions | @react-router/fs-routes, @react-router/remix-routes-option-adapter |
File-system routing, migration helpers |
Additional packages: react-router-dom (legacy re-export shim), create-react-router (project scaffolding CLI).
1.2 Request Lifecycle
A request through a React Router SSR application follows this path:
HTTP Request
→ Server Adapter (Express/Node/Cloudflare/Architect)
→ createRequestHandler({ build, getLoadContext })
→ matchRoutes(url)
→ Execute loaders (parallel by default)
→ Render React tree with RouterProvider
→ Serialize to HTML stream
→ Server Adapter writes HTTP Response
For client-side navigations after hydration:
Link click / navigate()
→ history.pushState
→ matchRoutes(url)
→ Fetch loaders via /__data endpoint (or single-fetch turbo-stream)
→ Re-render changed route segments
→ ScrollRestoration
1.3 Rendering Strategies
React Router supports three rendering strategies configured via react-router.config.ts:
| Strategy | Config | Build Output | Runtime |
|---|---|---|---|
| SSR (default) | ssr: true |
Client bundle + Server bundle | Server renders on every request |
| SPA | ssr: false |
Client bundle + static HTML shell | Client-only rendering, no server |
| Prerender | prerender: true | string[] | fn |
Client bundle + static HTML per path | Static files served by any HTTP server |
Note
When
ssr: false, React Router generates a static HTML shell with the client bundle. Loaders still run but execute entirely on the client. Server-side loaders, actions, and headers exports are not available in SPA mode.
1.4 Module System
All packages produce dual ESM/CJS builds via tsup. The build pipeline uses __DEV__ defines to strip development-only code (warnings, validation) from production bundles. Each package's package.json uses conditional exports:
{
"exports": {
".": {
"import": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
}
}
}
2. System Requirements
2.1 Runtime Requirements
| Requirement | Version | Notes |
|---|---|---|
| Node.js | >= 20.0.0 | Required for development and production server |
| React | >= 18.0.0 | React 19 supported with RSC features |
| TypeScript | >= 5.1 | Optional but strongly recommended; required for typegen |
2.2 Build Requirements
| Tool | Version | Notes |
|---|---|---|
| pnpm | 9.10.0 | Workspace protocol, catalog feature used |
| Vite | 5.x, 6.x, 7-beta, 8.x | Plugin supports multiple major versions |
| tsup | Latest | Package builds only (not application builds) |
2.3 Platform Support
| Platform | Adapter Package | Session Storage |
|---|---|---|
| Node.js (Express) | @react-router/express |
Cookie, Memory, File |
| Node.js (raw HTTP) | @react-router/node |
Cookie, Memory, File |
| Node.js (built-in) | @react-router/serve |
Cookie, Memory, File |
| Cloudflare Workers/Pages | @react-router/cloudflare |
Cookie, Memory, KV |
| AWS Lambda (Architect) | @react-router/architect |
Cookie, Memory, DynamoDB |
Important
The
@react-router/servepackage is designed for production use. It includes Express with compression, morgan logging, and static file serving with appropriate cache headers. It is the default server when deploying withreact-router-serve.
2.4 Environment Variables
| Variable | Default | Used By | Purpose |
|---|---|---|---|
NODE_ENV |
"development" |
All packages | Controls __DEV__ flag, logging verbosity, error detail |
PORT |
3000 |
@react-router/serve, framework servers |
HTTP listen port |
HOST |
"0.0.0.0" |
@react-router/serve |
HTTP bind address |
REACT_ROUTER_ROOT |
process.cwd() |
@react-router/dev config loader |
Override project root directory |
ARC_SANDBOX |
None | @react-router/architect |
Architect sandbox path for local development |
3. Configuration Reference
3.1 react-router.config.ts
The primary configuration file lives at the project root. It must provide a default export of type Config:
import type { Config } from "@react-router/dev/config";
export default {
// All options shown with defaults
} satisfies Config;
Complete Option Reference
| Option | Type | Default | Description |
|---|---|---|---|
appDirectory |
string |
"app" |
Directory containing application source (routes, entry files, root.tsx) |
basename |
string |
"/" |
Base path for all routes. Set when deploying to a subdirectory |
buildDirectory |
string |
"build" |
Output directory for production builds |
serverBuildFile |
string |
"index.js" |
Filename for the server bundle entry point |
serverModuleFormat |
"esm" | "cjs" |
"esm" |
Module format for server bundle output |
ssr |
boolean |
true |
Enable server-side rendering. false produces SPA mode |
prerender |
boolean | string[] | Function | Object |
false |
Static prerendering configuration (see below) |
routeDiscovery |
Object |
{ mode: "lazy" } |
Controls how the route manifest is delivered to the client |
serverBundles |
Function |
None | Split server code into named bundles based on route branches |
allowedActionOrigins |
string[] |
[] |
Additional origins allowed for form submissions (CSRF) |
future |
Object |
{} |
Feature flags for upcoming versions |
presets |
Preset[] |
[] |
Configuration presets that can override settings |
buildEnd |
Function |
None | Hook called after build completes |
Prerender Configuration
The prerender option accepts several forms:
// Prerender all static routes
prerender: true
// Prerender specific paths
prerender: ["/", "/about", "/pricing"]
// Dynamic path resolution
prerender: async ({ getStaticPaths }) => {
const paths = await getStaticPaths();
return ["/", ...paths];
}
// Object form with concurrency control
prerender: {
paths: ["/", "/about"],
unstable_concurrency: 4, // Parallel prerender workers
}
Route Discovery
routeDiscovery: {
mode: "lazy", // "lazy" (default) or "initial"
manifestPath: undefined // Custom path for the route manifest
}
In lazy mode, the route manifest is fetched on demand as the user navigates. In initial mode, the full manifest is included in the initial HTML payload — larger initial load but no manifest fetch waterfall.
Server Bundles
serverBundles: ({ branch }) => {
// branch is the array of matched routes
const isAdmin = branch.some(r => r.id.startsWith("routes/admin"));
return isAdmin ? "admin" : "main";
}
This splits the server build into separate bundles, useful for deploying different route trees to different infrastructure (e.g., admin routes to a separate Lambda).
Future Flags
future: {
v8_middleware: boolean, // Enable route middleware
v8_splitRouteModules: boolean | "enforce", // Tree-shake unused route exports
v8_viteEnvironmentApi: boolean, // Use Vite's Environment API
unstable_optimizeDeps: boolean, // Pre-bundle dependencies
unstable_passThroughRequests: boolean, // Pass unmatched requests through
unstable_subResourceIntegrity: boolean, // SRI hashes on script/link tags
unstable_trailingSlashAwareDataRequests: boolean, // Match trailing slashes in data requests
unstable_previewServerPrerendering: boolean, // Preview prerendered output in dev
}
Warning
Flags prefixed with
v8_will become the default behavior in React Router v8 and cannot be disabled after upgrade. Plan migration early. Flags prefixed withunstable_may change or be removed without notice.
3.2 Route Configuration via routes.ts
Routes are defined in app/routes.ts using helper functions:
import {
route,
index,
layout,
prefix,
relative,
getAppDirectory,
} from "@react-router/dev/routes";
import type { RouteConfig } from "@react-router/dev/routes";
export default [
index("routes/home.tsx"),
route("about", "routes/about.tsx"),
layout("routes/auth/layout.tsx", [
route("login", "routes/auth/login.tsx"),
route("register", "routes/auth/register.tsx"),
]),
...prefix("api", [
route("users", "routes/api/users.tsx"),
route("users/:id", "routes/api/users.$id.tsx"),
]),
] satisfies RouteConfig;
Route Helper API
| Helper | Signature | Purpose |
|---|---|---|
route |
route(path, file, children?) |
Define a route with a URL path |
index |
index(file) |
Define an index route (renders at parent path) |
layout |
layout(file, children?) |
Define a pathless layout wrapper |
prefix |
prefix(path, children) |
Add a path prefix to child routes without a layout |
relative |
relative(dir, routes) |
Resolve file paths relative to a directory |
getAppDirectory |
getAppDirectory() |
Returns the configured appDirectory path |
3.3 File-System Routing (@react-router/fs-routes)
When using the @react-router/fs-routes package, routes are derived from the filesystem:
// app/routes.ts
import { flatRoutes } from "@react-router/fs-routes";
export default flatRoutes();
Naming Conventions
| Pattern | URL | Example File |
|---|---|---|
users.tsx |
/users |
Layout for /users/* |
users._index.tsx |
/users (index) |
Index route |
users.$id.tsx |
/users/:id |
Dynamic parameter |
users.($id).tsx |
/users/:id? |
Optional parameter |
files.$.tsx |
/files/* |
Splat/catch-all |
_auth.login.tsx |
/login |
Pathless layout child |
[sitemap.xml].tsx |
/sitemap.xml |
Escaped literal dots |
users_.new.tsx |
/users/new |
Opt out of parent layout |
Supported file extensions: .js, .jsx, .ts, .tsx, .md, .mdx.
Tip
The
.character in filenames translates to/in URL paths. Use[brackets]to include literal characters that would otherwise be interpreted as routing syntax. For example,[sitemap.xml].tsxcreates a route at/sitemap.xml.
3.4 Vite Configuration
The React Router Vite plugin wraps around Vite's standard configuration:
// vite.config.ts
import { reactRouter } from "@react-router/dev/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [reactRouter()],
});
Cloudflare Configuration
import { reactRouter } from "@react-router/dev/vite";
import { cloudflareDevProxy } from "@react-router/dev/vite/cloudflare";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [
cloudflareDevProxy(),
reactRouter(),
],
});
Or with the official Cloudflare plugin:
import { reactRouter } from "@react-router/dev/vite";
import { cloudflare } from "@cloudflare/vite-plugin";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [
cloudflare({ viteEnvironment: { name: "ssr" } }),
reactRouter(),
],
});
3.5 Entry Files
Two optional entry files control the client and server rendering boundaries:
app/entry.client.tsx — Controls client-side hydration:
import { HydratedRouter } from "react-router/dom";
import { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";
startTransition(() => {
hydrateRoot(
document,
<StrictMode>
<HydratedRouter />
</StrictMode>
);
});
app/entry.server.tsx — Controls server-side rendering:
import { isbot } from "isbot";
import { renderToReadableStream } from "react-dom/server";
import { ServerRouter } from "react-router";
import type { EntryContext } from "react-router";
export default async function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
routerContext: EntryContext,
) {
const stream = await renderToReadableStream(
<ServerRouter context={routerContext} url={request.url} />,
{
signal: request.signal,
onError(error) {
responseStatusCode = 500;
console.error(error);
},
}
);
if (isbot(request.headers.get("user-agent"))) {
await stream.allReady;
}
return new Response(stream, {
status: responseStatusCode,
headers: responseHeaders,
});
}
Use npx react-router reveal entry.client or npx react-router reveal entry.server to generate default entry files for customization.
4. Deployment & Scaling
4.1 Build Output
Running react-router build produces:
build/
client/
assets/ # Hashed JS/CSS chunks (immutable, cache 1 year)
favicon.ico # Static assets from public/
server/
index.js # Server entry point (or custom serverBuildFile)
Important
Files in
build/client/assets/use content-hash filenames and should be served withCache-Control: public, max-age=31536000, immutable. The@react-router/servepackage handles this automatically. Custom server setups must configure this explicitly.
4.2 Deployment Targets
Node.js with @react-router/serve (Recommended)
The simplest production deployment:
npx react-router-serve build/server/index.js
This starts an Express server with:
- Compression middleware (gzip/brotli)
- Morgan access logging
- Static file serving with appropriate cache headers
- Source map support (when
NODE_ENV !== "production")
Environment variables: PORT (default 3000), HOST (default 0.0.0.0).
For RSC builds, react-router-serve detects the build type and uses the appropriate rendering pipeline.
Node.js with Express
import express from "express";
import { createRequestHandler } from "@react-router/express";
const app = express();
// Serve static assets with long-term caching
app.use(
"/assets",
express.static("build/client/assets", {
immutable: true,
maxAge: "1y",
})
);
// Serve other static files
app.use(express.static("build/client", { maxAge: "1h" }));
// Handle SSR requests
app.all(
"*",
createRequestHandler({
build: await import("./build/server/index.js"),
getLoadContext: (req, res) => ({
// Custom context available in loaders/actions
}),
})
);
app.listen(3000);
Node.js Raw HTTP Server
import { createServer } from "node:http";
import { createRequestListener } from "@react-router/node";
const listener = createRequestListener({
build: await import("./build/server/index.js"),
});
createServer(listener).listen(3000);
Cloudflare Workers
// server.ts
import { createRequestHandler } from "@react-router/cloudflare";
const handler = createRequestHandler(
// @ts-expect-error virtual module
() => import("virtual:react-router/server-build"),
import.meta.env.MODE
);
export default {
fetch(request, env, ctx) {
return handler(request, {
cloudflare: { env, ctx },
});
},
};
AWS Lambda via Architect
// server.ts
import { createRequestHandler } from "@react-router/architect";
export const handler = createRequestHandler({
build: await import("./build/server/index.js"),
});
4.3 Server Bundles for Multi-Region / Split Deploys
The serverBundles config option splits the server build by route branch, enabling deployment of different route trees to different infrastructure:
// react-router.config.ts
export default {
serverBundles: ({ branch }) => {
if (branch.some(r => r.id.startsWith("routes/api"))) {
return "api"; // Deploy to edge workers
}
return "ssr"; // Deploy to origin server
},
} satisfies Config;
This produces separate bundles in build/server/api/index.js and build/server/ssr/index.js.
4.4 Static Prerendering
For sites that can be fully or partially prerendered:
export default {
prerender: {
paths: async ({ getStaticPaths }) => {
const posts = await fetchBlogPosts();
return [
"/",
"/about",
...posts.map(p => `/blog/${p.slug}`),
];
},
unstable_concurrency: 4,
},
} satisfies Config;
Prerendered output goes to build/client/ as static HTML files. These can be served by any static file server (nginx, CDN, S3+CloudFront).
4.5 Docker Deployment
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM node:20-alpine AS production
WORKDIR /app
COPY --from=build /app/build ./build
COPY --from=build /app/package.json ./
COPY --from=build /app/node_modules ./node_modules
ENV NODE_ENV=production
EXPOSE 3000
CMD ["npx", "react-router-serve", "build/server/index.js"]
4.6 Scaling Considerations
React Router SSR applications are stateless by default (session state lives in cookies or external stores). Horizontal scaling requires:
- Session storage — Use cookie-based sessions (no server affinity needed) or external stores (Redis, DynamoDB, Cloudflare KV)
- Static assets — Serve from CDN. The content-hashed filenames in
build/client/assets/enable aggressive caching - Server bundles — Use
serverBundlesto split compute-heavy routes to dedicated infrastructure - Prerendering — Prerender static pages to eliminate server compute entirely
5. Operations
5.1 CLI Reference
The react-router CLI provides five commands:
# Production build
react-router build [projectDir]
# Outputs: build/client/ and build/server/
# Development server with HMR
react-router dev [projectDir]
# Starts Vite dev server with SSR middleware
# Display route tree
react-router routes [projectDir]
# Add --json for JSON output
# Generate entry files for customization
react-router reveal [entry.client|entry.server]
# Creates app/entry.client.tsx or app/entry.server.tsx
# Generate route types
react-router typegen
# Add --watch for watch mode
# Outputs to .react-router/types/
5.2 Type Generation
React Router automatically generates TypeScript types for route modules. The typegen system produces types in .react-router/types/ that provide type safety for:
- Loader and action function parameters
useLoaderData()return types- Route parameters (
useParams()) - Search parameters
Generated types are placed alongside route files in the type output directory:
.react-router/
types/
app/
routes/
+types.home.ts
+types.users.$id.ts
Each generated file exports types specific to that route's parameters, loader data, and action data. These types are automatically imported when using the Route.LoaderArgs, Route.ActionArgs, and similar type helpers.
Note
Run
react-router typegen --watchduring development to keep types in sync as routes change. The Vite plugin runs typegen automatically duringreact-router dev.
5.3 Build Output Verification
After building, verify the output:
# Check build output exists
ls -la build/client/assets/
ls -la build/server/
# Verify server bundle is valid
node -e "import('./build/server/index.js').then(m => console.log('OK', Object.keys(m)))"
# Check asset sizes
du -sh build/client/assets/*
5.4 Health Monitoring
For production SSR deployments, implement health checks:
// app/routes/healthz.tsx
export function loader() {
return new Response("OK", {
status: 200,
headers: { "Cache-Control": "no-store" },
});
}
Key metrics to monitor:
| Metric | Source | Alert Threshold |
|---|---|---|
| SSR response time | Server logs / APM | p99 > 500ms |
| Loader execution time | Custom instrumentation | Individual loader > 200ms |
| Client bundle size | Build output | Main chunk > 250KB gzipped |
| Hydration time | Client-side RUM | > 2s on 3G |
| Error boundary hits | Server logs | Any increase |
5.5 Logging
@react-router/serve uses Morgan for HTTP access logging. For custom servers, instrument at the adapter level:
app.all("*", (req, res, next) => {
const start = Date.now();
res.on("finish", () => {
console.log(JSON.stringify({
method: req.method,
path: req.path,
status: res.statusCode,
duration: Date.now() - start,
}));
});
next();
}, createRequestHandler({ build }));
5.6 Error Handling
React Router uses a hierarchical error boundary system:
- Route-level
ErrorBoundary— Catches errors in that route's loader, action, or component - Parent route
ErrorBoundary— Catches errors that bubble up from child routes - Root
ErrorBoundary— Last resort, defined inapp/root.tsx - Server-level error handler —
entry.server.tsxonErrorcallback for streaming errors
// app/root.tsx
export function ErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return <div>{error.status} {error.statusText}</div>;
}
return <div>Unexpected error</div>;
}
Caution
Never expose raw error messages or stack traces to end users in production. The
ErrorBoundarycomponent receives the full error object, but your rendering logic should display a user-friendly message. SetNODE_ENV=productionto suppress detailed error output from the framework.
6. Performance
6.1 Data Loading Optimization
React Router executes loaders in parallel for all matched routes. This is the primary performance lever:
GET /users/123/posts
→ root loader ─┐
→ users loader ├── All execute in parallel
→ users.$id loader │
→ posts loader ─┘
Optimize loaders by:
- Avoiding waterfall dependencies — Each loader should fetch its own data independently
- Using
shouldRevalidate— Prevent unnecessary re-fetching on navigations that don't affect a route's data - Deferring non-critical data — Return promises from loaders to stream data after the initial shell
export function shouldRevalidate({
currentUrl,
nextUrl,
defaultShouldRevalidate,
}) {
// Only revalidate if the search params changed
if (currentUrl.searchParams.toString() === nextUrl.searchParams.toString()) {
return false;
}
return defaultShouldRevalidate;
}
6.2 Code Splitting
React Router automatically code-splits by route. Each route module becomes a separate chunk loaded on demand. Additional strategies:
v8_splitRouteModules— When enabled, tree-shakes unused exports from route modules. If a route only exports aloaderanddefaultcomponent (noaction, noErrorBoundary), the unused exports are eliminatedrouteDiscovery.mode: "lazy"— The route manifest itself is loaded incrementally, reducing initial payload for large route trees- Dynamic imports within components — Use
React.lazy()for heavy sub-components within a route
6.3 Caching Strategy
| Asset Type | Path Pattern | Cache Header |
|---|---|---|
| Hashed assets | /assets/* |
public, max-age=31536000, immutable |
| Static files | /favicon.ico, etc. |
public, max-age=3600 |
| HTML pages | /* |
no-cache or short TTL |
| Data requests | /__data |
Set via loader headers export |
Each route can export a headers function to control response caching:
export function headers({ loaderHeaders }) {
return {
"Cache-Control": loaderHeaders.get("Cache-Control") ?? "public, max-age=300",
};
}
6.4 Bundle Analysis
Vite's built-in build.rollupOptions can be used with analysis plugins:
// vite.config.ts
import { visualizer } from "rollup-plugin-visualizer";
export default defineConfig({
plugins: [reactRouter()],
build: {
rollupOptions: {
plugins: [visualizer({ open: true })],
},
},
});
6.5 Streaming SSR
React Router streams HTML by default using renderToReadableStream. The initial shell renders immediately with <Suspense> boundaries, and deferred data streams in as it resolves. This provides fast Time to First Byte (TTFB) while still server-rendering the complete page.
For bot detection (ensuring crawlers receive complete HTML):
// entry.server.tsx
if (isbot(request.headers.get("user-agent"))) {
await stream.allReady; // Wait for all content before responding
}
7. Security
7.1 CSRF Protection
React Router includes built-in CSRF protection for form submissions (actions). The mechanism in packages/react-router/lib/actions.ts:
- On every non-GET request,
throwIfPotentialCSRFAttack()compares theOriginheader againstHostandX-Forwarded-Hostheaders - If the origin does not match, the request is rejected with a 403 response
- Additional origins can be allowed via
allowedActionOriginsin the config
// react-router.config.ts
export default {
allowedActionOrigins: [
"https://admin.example.com",
"*.example.com", // Wildcard subdomain
"**.example.com", // Deep wildcard
],
} satisfies Config;
Wildcard matching uses micromatch glob patterns.
Warning
The CSRF check relies on the
Originheader sent by browsers. API clients that do not send anOriginheader (e.g.,curl, server-to-server calls) will be blocked. For API routes that accept non-browser requests, handle CSRF differently (e.g., API tokens).
7.2 Session Security
Cookie Session Storage
import { createCookieSessionStorage } from "react-router";
const { getSession, commitSession, destroySession } =
createCookieSessionStorage({
cookie: {
name: "__session",
httpOnly: true, // Not accessible via JavaScript
maxAge: 60 * 60 * 24, // 24 hours
path: "/",
sameSite: "lax", // CSRF protection
secrets: ["s3cr3t"], // Signing key(s) — first is active, rest for rotation
secure: true, // HTTPS only
},
});
Session security checklist:
| Setting | Required Value | Risk if Wrong |
|---|---|---|
httpOnly |
true |
XSS can steal session cookies |
secure |
true (production) |
Session sent over HTTP, interceptable |
sameSite |
"lax" or "strict" |
CSRF via cross-site form submissions |
secrets |
Non-empty array | Unsigned cookies, trivially forgeable |
maxAge |
Bounded value | Sessions that never expire |
Caution
Cookie session secrets should be cryptographically random strings of at least 32 characters. Rotate secrets by prepending new secrets to the array — the first secret signs new cookies, while older secrets verify existing cookies during the rotation period.
Server-Side Session Storage
For sessions that should not be stored in cookies (large payloads, sensitive data):
| Storage | Package | Use Case |
|---|---|---|
createFileSessionStorage() |
@react-router/node |
Single-server deployments |
| Cloudflare KV | @react-router/cloudflare |
Cloudflare Workers |
| DynamoDB | @react-router/architect |
AWS deployments |
createMemorySessionStorage() |
react-router |
Development/testing only |
7.3 Content Security
React Router does not inject inline scripts by default in SSR mode. The hydration data is serialized as a <script> tag with a deterministic structure. For strict CSP:
// entry.server.tsx — add nonce to scripts
export default function handleRequest(
request: Request,
status: number,
headers: Headers,
context: EntryContext,
) {
const nonce = crypto.randomUUID();
headers.set(
"Content-Security-Policy",
`script-src 'nonce-${nonce}' 'strict-dynamic';`
);
// Pass nonce to renderToReadableStream options
}
The unstable_subResourceIntegrity future flag adds SRI hashes to <script> and <link> tags for additional integrity verification.
7.4 Module Boundary Enforcement
React Router enforces server/client module boundaries:
- Files/directories named
.server(e.g.,db.server.ts,.server/) are excluded from client bundles - Files/directories named
.clientare excluded from server bundles - Attempting to import a
.servermodule from client code produces a build error
Tip
Use the
.serversuffix convention for any module containing secrets, database connections, or server-only dependencies. This prevents accidental bundling of sensitive code into the client JavaScript.
7.5 Headers Export
Each route module can export a headers function to set security headers:
export function headers() {
return {
"X-Frame-Options": "DENY",
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "strict-origin-when-cross-origin",
"Permissions-Policy": "camera=(), microphone=(), geolocation=()",
};
}
For headers that apply to all routes, set them at the server adapter level (Express middleware, Cloudflare Workers headers, etc.).
8. Developer Internals
8.1 Core Router Engine
The router core lives in packages/react-router/lib/router/ and implements:
History Management — Wraps the browser History API (or in-memory history for SSR/tests) with:
pushState/replaceStateabstraction- Blocker support (preventing navigation with unsaved changes)
- Pop state event handling
Route Matching — Implements a ranked route matching algorithm:
- Routes are scored by specificity (static segments > dynamic > splat)
- Matching considers nested route trees with relative paths
PrefixLookupTrie(in@react-router/fs-routes) provides O(prefix-length) lookup for file-system routes
Data Loading — The data pipeline:
- Match routes for the target URL
- Determine which loaders need to run (respecting
shouldRevalidate) - Execute loaders in parallel via
Promise.all - For actions: execute the action first, then revalidate affected loaders
- Handle redirects at any point in the pipeline
- Commit navigation state and trigger React re-render
8.2 React Context Architecture
React Router distributes state through several React contexts defined in packages/react-router/lib/context.tsx:
| Context | Purpose | Consumer Hooks |
|---|---|---|
DataRouterContext |
Router instance, navigator | useNavigate, useSubmit |
DataRouterStateContext |
Current router state (location, matches, errors) | useLocation, useMatches |
RouteContext |
Current route match, outlet | useOutlet, useParams |
RouteErrorContext |
Error for current route | useRouteError |
NavigationContext |
Navigation helpers | useNavigate, useLinkClickHandler |
8.3 Component Hierarchy
<RouterProvider router={router}>
<DataRouterContext.Provider>
<DataRouterStateContext.Provider>
<Router>
<NavigationContext.Provider>
<LocationContext.Provider>
<Routes>
<Route element={<RootLayout />}>
<Outlet /> ← Renders matched child route
</Route>
</Routes>
</LocationContext.Provider>
</NavigationContext.Provider>
</Router>
</DataRouterStateContext.Provider>
</DataRouterContext.Provider>
</RouterProvider>
8.4 Key Internal Classes
| Class | Location | Purpose |
|---|---|---|
PrefixLookupTrie |
packages/react-router-fs-routes |
Trie-based route matching for file-system routes |
StreamPump |
packages/react-router-node |
Converts Web ReadableStream to Node.js streams |
Deferred |
packages/react-router/lib/components.tsx |
Internal promise wrapper for <Await> component |
AwaitErrorBoundary |
packages/react-router/lib/components.tsx |
Error boundary wrapping deferred data resolution |
RenderErrorBoundary |
packages/react-router/lib/components.tsx |
Error boundary for route component rendering |
PlaywrightFixture |
Integration tests | Helper for Playwright-based integration tests |
CopyTemplateError |
packages/create-react-router |
Error during project scaffolding |
8.5 Vite Plugin Internals
The Vite plugin (packages/react-router-dev/vite/) orchestrates the build and dev experience:
Config Resolution:
- Load
react-router.config.tsvia Vite's module resolution - Apply presets (presets can modify any config option)
- Resolve route tree from
routes.tsor file-system convention - Generate virtual modules for server build entry
Development Server:
- Vite dev server starts with SSR middleware
- Routes are watched for changes → automatic typegen
- HMR handles component updates without full page reload
- Server modules are re-evaluated on change (no server restart needed)
Production Build:
- Client build: Vite builds the client entry with route-based code splitting
- Server build: Vite builds the server entry with route handlers inlined
- Manifest generation: Asset manifest for SSR → client asset resolution
- Type generation: Final pass of route types
Virtual Modules:
virtual:react-router/server-build— Resolved server build for adaptersvirtual:react-router/browser-manifest— Client route manifest
8.6 RSC Support (Unstable)
React Server Components support lives in packages/react-router/lib/rsc/ and is considered unstable. It provides:
- Server-side component rendering without client JavaScript
- Integration with React's streaming SSR pipeline
@react-router/servedetects RSC builds and uses the appropriate server entry
8.7 Module Analysis by Package
react-router (Core)
| Module Path | Purpose |
|---|---|
lib/router/router.ts |
Core router state machine, navigation, data loading |
lib/router/history.ts |
History abstraction (browser, hash, memory) |
lib/router/utils.ts |
Route matching, path parsing, ranking |
lib/components.tsx |
RouterProvider, Route, Routes, Outlet, Navigate, Await |
lib/hooks.tsx |
useNavigate, useLocation, useParams, useLoaderData, useActionData, useRouteError, etc. |
lib/dom/global.ts |
BrowserRouter, HashRouter setup |
lib/dom/lib.tsx |
Link, NavLink, Form, ScrollRestoration, useFetcher, useSearchParams |
lib/dom/ssr/components.tsx |
HydratedRouter, ServerRouter, Meta, Links, Scripts |
lib/dom/ssr/entry.ts |
EntryContext for SSR |
lib/dom/ssr/routes.tsx |
Route module loading and caching |
lib/dom/ssr/single-fetch.tsx |
Single-fetch data endpoint (turbo-stream) |
lib/server-runtime/server.ts |
createRequestHandler for server adapters |
lib/server-runtime/sessions.ts |
Session API, createSessionStorage |
lib/server-runtime/cookies.ts |
Cookie parsing, signing, serialization |
lib/server-runtime/data.ts |
Loader/action data serialization |
lib/context.tsx |
All React context definitions |
lib/actions.ts |
CSRF protection, action handling |
lib/rsc/ |
React Server Components (unstable) |
@react-router/dev (Build Tooling)
| Module Path | Purpose |
|---|---|
vite/plugin.ts |
Main Vite plugin entry point |
vite/dev.ts |
Development server middleware |
vite/build.ts |
Production build orchestration |
vite/styles.ts |
CSS handling and HMR |
config/config.ts |
Configuration loading and validation |
config/routes.ts |
Route tree resolution |
typegen/index.ts |
Type generation orchestrator |
typegen/generate.ts |
Route type file generation |
cli/run.ts |
CLI command dispatch |
@react-router/serve (Production Server)
| Module Path | Purpose |
|---|---|
src/index.ts |
Express server setup: compression, morgan, static serving, request handler |
bin/react-router-serve.ts |
CLI entry point, port/host resolution |
@react-router/node
| Module Path | Purpose |
|---|---|
sessions/fileStorage.ts |
File-based session storage with atomic writes |
stream.ts |
Web stream ↔ Node.js stream conversion via StreamPump |
index.ts |
createRequestListener for raw HTTP servers |
@react-router/cloudflare
| Module Path | Purpose |
|---|---|
worker.ts |
Cloudflare Workers request handler adapter |
sessions/kvStorage.ts |
Cloudflare KV session storage |
@react-router/architect
| Module Path | Purpose |
|---|---|
server.ts |
AWS Lambda event → Web Request conversion |
sessions/dynamodbStorage.ts |
DynamoDB session storage |
@react-router/fs-routes
| Module Path | Purpose |
|---|---|
flatRoutes.ts |
File-system to route tree conversion |
routeManifest.ts |
Route manifest generation from filesystem |
8.8 Testing Infrastructure
Unit Tests (Jest):
- Each package has
jest.config.jsextending shared config fromjest/jest.config.shared - Tests co-located with source or in
__tests__/directories createRoutesStub()utility for testing route modules in isolation
Integration Tests (Playwright):
- Located in
integration/directory playwright.config.tsat integration rootPlaywrightFixtureclass provides helpers for:- Starting dev/production servers
- Navigation and interaction
- Network request interception
- Console log capture
// Testing a route module
import { createRoutesStub } from "react-router";
import { render, screen } from "@testing-library/react";
const Stub = createRoutesStub([
{
path: "/",
Component: MyComponent,
loader: () => ({ message: "hello" }),
},
]);
render(<Stub initialEntries={["/"]} />);
9. Integration Points
9.1 create-react-router (Scaffolding)
The create-react-router package provides project scaffolding:
npx create-react-router@latest my-app
# Or with a specific template
npx create-react-router@latest my-app --template remix-run/react-router/templates/basic
Templates are fetched from GitHub repositories. The scaffolding process:
- Clone or download the template
- Replace template placeholders
- Initialize git repository
- Display setup instructions
CopyTemplateError is thrown when template files cannot be written (permissions, disk space).
9.2 MDX Integration
React Router supports MDX files as route modules when configured with an MDX Vite plugin:
// vite.config.ts
import mdx from "@mdx-js/rollup";
import { reactRouter } from "@react-router/dev/vite";
export default defineConfig({
plugins: [mdx(), reactRouter()],
});
MDX files (.md, .mdx) in the routes directory are treated as route modules. They can export loader, action, headers, meta, and handle alongside their content.
9.3 Presets System
Presets allow third-party packages to provide React Router configuration:
// A preset package
import type { Preset } from "@react-router/dev/config";
export function myPreset(): Preset {
return {
name: "my-preset",
reactRouterConfig: () => ({
serverModuleFormat: "cjs",
// Any config option can be set
}),
reactRouterVitePlugin: () => ({
// Vite plugin options
}),
};
}
// Usage in react-router.config.ts
import { myPreset } from "my-preset";
export default {
presets: [myPreset()],
} satisfies Config;
Presets are applied in order; later presets override earlier ones. Direct config options override all presets.
9.4 getLoadContext
Every server adapter supports getLoadContext to inject server-specific context into loaders and actions:
// Express
createRequestHandler({
build,
getLoadContext: (req, res) => ({
db: req.app.locals.db,
user: req.user,
}),
});
// Cloudflare Workers
handler(request, {
cloudflare: { env, ctx },
});
The context is available as the second argument to loaders and actions:
export function loader({ request, context }) {
const db = context.db;
// ...
}
9.5 @react-router/remix-routes-option-adapter
For projects migrating from Remix, this adapter translates Remix's routes option format to React Router's routes.ts format:
// app/routes.ts
import { remixRoutesOptionAdapter } from "@react-router/remix-routes-option-adapter";
export default remixRoutesOptionAdapter((defineRoutes) => {
return defineRoutes((route) => {
route("/", "routes/home.tsx", { index: true });
route("/about", "routes/about.tsx");
});
});
9.6 Known Issues and Workarounds
| Issue | Description | Workaround |
|---|---|---|
| #15007 | Concurrent Vite config resolution leaks route state when using flatRoutes() |
Avoid parallel Vite config resolution in custom tooling |
| #14754 | Duplicated styles with suspended components since v7.9.5 | Pin to v7.9.4 or monitor for patch |
| #14997 | Server-only module error with .server directory naming |
Use .server.ts file suffix instead of .server/ directory |
| #14991 | pnpm requires trustPolicyExclude for @react-router/dev |
Add @react-router/dev to pnpm's trustPolicyExclude |
| #14744 | Typegen bug with * in folder names |
Avoid * in directory names |
| #14903 | Middleware not handling redirects with useFetcher |
Handle redirects in the fetcher action directly |
Note
Check the React Router GitHub repository for the latest status on these issues. Many may be resolved in newer releases beyond the source commit analyzed here.
9.7 Build End Hook
The buildEnd hook runs after the production build completes:
// react-router.config.ts
export default {
buildEnd: async ({ buildManifest, reactRouterConfig, viteConfig }) => {
// Generate sitemap from build manifest
// Upload assets to CDN
// Notify deployment pipeline
},
} satisfies Config;
9.8 Middleware (v8 Future Flag)
When future.v8_middleware is enabled, route modules can export middleware:
export const unstable_middleware = [
async function authMiddleware({ request, context }, next) {
const session = await getSession(request);
context.user = session.get("user");
return next();
},
];
Middleware runs before loaders/actions and can modify context, short-circuit with responses, or pass through to the next handler.
9.9 Quick Reference Tables
Exported Functions from Route Modules
| Export | Purpose | Server/Client |
|---|---|---|
default |
Route component | Client (+ SSR) |
loader |
Data loading (GET) | Server (or client in SPA) |
clientLoader |
Client-side data loading | Client only |
action |
Mutation handling (POST/PUT/DELETE) | Server (or client in SPA) |
clientAction |
Client-side mutation handling | Client only |
ErrorBoundary |
Error UI for this route | Client (+ SSR) |
headers |
HTTP response headers | Server |
handle |
Arbitrary data exposed via useMatches |
Both |
links |
<link> tags for this route |
Both |
meta |
<meta> and <title> tags |
Both |
shouldRevalidate |
Control loader re-execution | Client |
HydrateFallback |
UI shown during client hydration | Client |
unstable_middleware |
Request middleware (v8 flag) | Server |
Core Hooks Reference
| Hook | Returns | Purpose |
|---|---|---|
useLoaderData() |
Loader return value | Access route loader data |
useActionData() |
Action return value | Access route action data |
useParams() |
Params |
URL parameters |
useSearchParams() |
[URLSearchParams, setter] |
Query string parameters |
useLocation() |
Location |
Current location object |
useNavigate() |
NavigateFunction |
Programmatic navigation |
useNavigation() |
Navigation |
Pending navigation state |
useRouteError() |
unknown |
Error caught by error boundary |
useMatches() |
UIMatch[] |
All matched routes with handle data |
useFetcher() |
Fetcher |
Non-navigation data loading/mutations |
useFetchers() |
Fetcher[] |
All active fetchers |
useRouteLoaderData(routeId) |
Loader data | Access another route's loader data |
useRevalidator() |
{ revalidate, state } |
Manually trigger revalidation |
useBlocker() |
Blocker |
Prevent navigation (unsaved changes) |