ch24-react-router
對應 24. React Router v8 on Workers
在 GitHub 上檢視·12 個檔案·8.2 KB
取得並執行
這個範例可以獨立 clone 執行,不依賴其他章節。
npx degit mrsuner/cloudflare-serverless-platform-manuals/examples/ch24-react-router
cd ch24-react-router
npm install可用指令
npm run dev # react-router dev
npm run deploy # npm run build && wrangler deploy
npm run typecheck # react-router typegen && tsc --noEmit
npm run build # react-router build
npm run postinstall # wrangler types說明
ch24 — React Router v8 on Workers
Section titled “ch24 — React Router v8 on Workers”A working React Router v8 SSR app on Workers, plus probes for the things that changed between v7 and v8.
Verified with react-router 8.3.0, @react-router/dev 8.3.0, @cloudflare/vite-plugin 1.50.0, vite 8.2.0, react 19.2.8, node 22.22.2, wrangler 4.118.0.
npm installnpx wrangler typesnpx react-router dev --port 9026Requires Node ≥ 22.22.0.
The headline probe
Section titled “The headline probe”workers/app.ts has a route that deliberately passes the v7-era plain
object as load context — the exact shape Cloudflare’s own React Router guide
still shows today:
curl -s localhost:9026/__probe/plain-object | jq{ "status": 500, "body": "Unexpected Server Error\n\nError: Invalid `context` value provided to `handleRequest`. You must return an instance of `RouterContextProvider` from your `getLoadContext` function."}AppLoadContext appears zero times in react-router@8.3.0’s dist/:
grep -ro 'AppLoadContext' node_modules/react-router/dist/ | wc -l # 0Routes
Section titled “Routes”| Route | What it shows |
|---|---|
/ | The v8 binding pattern: import { env } from "cloudflare:workers" in a loader, with no context parameter at all. Queries D1. |
/probe | The explicit-context pattern for the two things env cannot give you: ExecutionContext (waitUntil) and request.cf. Also demonstrates middleware ordering around next(). |
/__probe/plain-object | The v7 pattern, returning its own 500. |
What the probes establish
Section titled “What the probes establish”# ctx.waitUntil and request.cf require an explicit RouterContextProvidercurl -s localhost:9026/probe# { "contextIsProvider": "RouterContextProvider", "hasWaitUntil": true, "colo": "DFW", ... }
# middleware: context.set before next() is visible to the loader; after is not# => { "seenBeforeNext": "set-before-next", "seenAfterNext": "never-set" }curl -si localhost:9026/probe | grep x-ch24-middleware-ms
# v8_passThroughRequests is now default: request.url keeps the .data suffixcurl -s localhost:9026/probe.data | head -c 200# ..."rawRequestUrl","http://localhost:9026/probe.data"...
# the root data path was renamed in v8curl -o /dev/null -w "%{http_code}\n" localhost:9026/_root.data # 404curl -o /dev/null -w "%{http_code}\n" localhost:9026/_.data # 200That last pair matters on Cloudflare: any Cache Rule, WAF rule, _routes.json,
or run_worker_first glob matching .data needs updating.
Build output
Section titled “Build output”npx react-router buildcat build/server/wrangler.jsonThe plugin generates the deploy config for you:
{ "main": "index.js", "assets": { "directory": "../client" }, "no_bundle": true, ... }- Do not hand-write an
assetsblock inwrangler.jsonc. - Your input config’s
mainpoints at TypeScript (./workers/app.ts); the generated config’smainpoints at the built chunk. Both are correct. .wrangler/deploy/config.jsonpointswrangler deployat the generated config, so deploy with a barewrangler deploy— never-c wrangler.jsonc.build/client/.assetsignoreis emitted automatically containingwrangler.jsonand.dev.vars.
npm run build && npx wrangler deployTwo bindings patterns, and when to use which
Section titled “Two bindings patterns, and when to use which”| Need | Pattern |
|---|---|
| KV / D1 / R2 / vars / secrets | import { env } from "cloudflare:workers" |
ctx.waitUntil() | createContext() + new RouterContextProvider() |
request.cf | same, or read request directly in the route |
| Injecting fakes in tests | context — dependency injection beats a module import |
createContext<T>() creates a typed key. The provider is created with
new RouterContextProvider(). They are not the same thing, and several
tutorials conflate them.
Package traps
Section titled “Package traps”react-router-domhas no 8.x. Last version is 7.18.2. Import fromreact-router/react-router/dom.json()anddefer()are gone — and the v8 upgrade guide never mentions them, because they were removed during the v7 line. Usedata()or return plain objects /Response.@react-router/cloudflare@8.3.0still publishes, but it is the Cloudflare Pages Functions adapter (PagesFunction<Env>,EventContext). A Workers app should not install it.vite-tsconfig-pathsis replaced by Vite’s built-inresolve: { tsconfigPaths: true }.react-router.config.tshas nofutureblock any more —FutureConfigin 8.3.0 contains onlyunstable_enableNodeReadableStreamandunstable_optimizeDeps.
原始碼
wrangler.jsoncpackage.jsonapp/entry.server.tsxapp/root.tsxapp/routes.tsapp/routes/home.tsxapp/routes/probe.tsxworkers/app.tsreact-router.config.tsvite.config.ts.gitignoretsconfig.json
wrangler.jsonc
{
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "ch24-react-router",
"main": "./workers/app.ts",
"compatibility_date": "2026-07-24",
"compatibility_flags": ["nodejs_compat"],
"observability": { "enabled": true },
"vars": { "VALUE_FROM_CLOUDFLARE": "hello from wrangler.jsonc" },
"d1_databases": [
{ "binding": "DB", "database_name": "ch24-links", "database_id": "ch24-local" }
]
}package.json
{
"name": "ch24-react-router",
"private": true,
"type": "module",
"scripts": {
"build": "react-router build",
"dev": "react-router dev",
"deploy": "npm run build && wrangler deploy",
"typecheck": "react-router typegen && tsc --noEmit",
"postinstall": "wrangler types"
},
"dependencies": {
"isbot": "^5.1.31",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-router": "^8.3.0"
},
"devDependencies": {
"@cloudflare/vite-plugin": "^1.50.0",
"@react-router/dev": "^8.3.0",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"typescript": "^5.9.2",
"vite": "^8.2.0",
"wrangler": "^4.118.0"
}
}app/entry.server.tsx
import { renderToReadableStream } from "react-dom/server";
import { ServerRouter } from "react-router";
import type { EntryContext } from "react-router";
import { isbot } from "isbot";
export default async function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
routerContext: EntryContext,
): Promise<Response> {
let didError = false;
const body = await renderToReadableStream(
<ServerRouter context={routerContext} url={request.url} />,
{
onError() {
didError = true;
},
},
);
if (isbot(request.headers.get("user-agent") ?? "")) await body.allReady;
responseHeaders.set("Content-Type", "text/html");
return new Response(body, {
headers: responseHeaders,
status: didError ? 500 : responseStatusCode,
});
}app/root.tsx
import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router";
import type { Route } from "./+types/root";
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
export default function App() {
return <Outlet />;
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
const message = isRouteErrorResponse(error)
? `${error.status} ${error.statusText}`
: error instanceof Error
? error.message
: "Unknown error";
return (
<main>
<h1>Error</h1>
<pre>{message}</pre>
</main>
);
}app/routes.ts
import { type RouteConfig, index, route } from "@react-router/dev/routes";
export default [
index("routes/home.tsx"),
route("probe", "routes/probe.tsx"),
] satisfies RouteConfig;app/routes/home.tsx
// The v8 way to reach bindings from a loader: no `context` at all.
import { env } from "cloudflare:workers";
import type { Route } from "./+types/home";
export function meta() {
return [{ title: "ch24 — React Router v8 on Workers" }];
}
export async function loader() {
// `env` is a module-scope import, but I/O still has to happen inside a
// request context. A loader IS inside one, so this is fine.
const row = await env.DB.prepare("select 1 as one").first<{ one: number }>();
return {
message: env.VALUE_FROM_CLOUDFLARE,
d1: row,
// Prove there is no `context.cloudflare` any more.
bindingsSeenFromModuleImport: Object.keys(env).sort(),
};
}
export default function Home({ loaderData }: Route.ComponentProps) {
return (
<main>
<h1>ch24 — React Router v8 on Workers</h1>
<p>{loaderData.message}</p>
<pre>{JSON.stringify(loaderData, null, 2)}</pre>
</main>
);
}app/routes/probe.tsx
import { env } from "cloudflare:workers";
import { cfContext } from "../../workers/app";
import type { Route } from "./+types/probe";
// Middleware is always on in v8 -- no future flag, no `unstable_` prefix.
import { createContext } from "react-router";
export const beforeNext = createContext<string>("never-set");
export const afterNext = createContext<string>("never-set");
const timing: Route.MiddlewareFunction = async ({ context }, next) => {
const started = Date.now();
// Code BEFORE next() runs before the loader. The loader will see this.
context.set(beforeNext, "set-before-next");
const res = await next();
// Code AFTER next() runs after the loader has already returned. Setting
// context here is too late for the loader -- it only affects the response.
context.set(afterNext, "set-after-next");
res.headers.set("x-ch24-middleware-ms", String(Date.now() - started));
return res;
};
export const middleware: Route.MiddlewareFunction[] = [timing];
export async function loader({ context, request }: Route.LoaderArgs) {
const cf = context.get(cfContext);
// waitUntil is only reachable through the explicit context, never through
// `import { env }`.
cf.ctx.waitUntil(Promise.resolve());
return {
contextIsProvider: context.constructor.name,
seenBeforeNext: context.get(beforeNext),
seenAfterNext: context.get(afterNext),
hasWaitUntil: typeof cf.ctx.waitUntil === "function",
colo: cf.cf?.colo ?? null,
// v8: request.url is the RAW url, including the `.data` suffix on
// client-side navigations. Compare with the normalized `url` arg.
rawRequestUrl: request.url,
envKeys: Object.keys(env).sort(),
};
}
export default function Probe({ loaderData }: Route.ComponentProps) {
return <pre>{JSON.stringify(loaderData, null, 2)}</pre>;
}workers/app.ts
import { createContext, createRequestHandler, RouterContextProvider } from "react-router";
/**
* Per-request Cloudflare handles that `import { env } from "cloudflare:workers"`
* cannot give you: the ExecutionContext (for waitUntil) and request.cf.
*
* In React Router v8 the ONLY legal way to reach a loader with these is a
* RouterContextProvider. Passing a plain object -- what every v7-era tutorial
* and Cloudflare's own guide still show -- is rejected at runtime.
*/
export const cfContext = createContext<{
ctx: ExecutionContext;
cf: IncomingRequestCfProperties | undefined;
}>();
const requestHandler = createRequestHandler(
() => import("virtual:react-router/server-build"),
import.meta.env.MODE,
);
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
// Probe: what happens if we pass the v7-style plain object?
if (url.pathname === "/__probe/plain-object") {
const res = await requestHandler(
new Request(new URL("/", url), request),
{ cloudflare: { env, ctx } } as unknown as RouterContextProvider,
);
return Response.json({ status: res.status, body: (await res.text()).slice(0, 200) });
}
const context = new RouterContextProvider();
context.set(cfContext, { ctx, cf: request.cf });
return requestHandler(request, context);
},
} satisfies ExportedHandler<Env>;react-router.config.ts
import type { Config } from "@react-router/dev/config";
export default {
ssr: true,
} satisfies Config;vite.config.ts
import { reactRouter } from "@react-router/dev/vite";
import { cloudflare } from "@cloudflare/vite-plugin";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [
// `cloudflare` must come first, and the Worker is assigned to the framework's
// `ssr` environment so the two builds merge into one output.
cloudflare({ viteEnvironment: { name: "ssr" } }),
reactRouter(),
],
resolve: { tsconfigPaths: true },
});.gitignore
node_modules/
build/
.react-router/
.wrangler/
worker-configuration.d.tstsconfig.json
{
"include": [
"**/*.ts",
"**/*.tsx",
".react-router/types/**/*",
"worker-configuration.d.ts"
],
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"types": ["vite/client"],
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"rootDirs": [".", "./.react-router/types"],
"baseUrl": ".",
"paths": { "~/*": ["./app/*"] },
"esModuleInterop": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"strict": true
}
}