跳到內容

ch24-react-router

取得並執行

這個範例可以獨立 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

說明

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.

Terminal window
npm install
npx wrangler types
npx react-router dev --port 9026

Requires Node ≥ 22.22.0.

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:

Terminal window
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/:

Terminal window
grep -ro 'AppLoadContext' node_modules/react-router/dist/ | wc -l # 0
RouteWhat it shows
/The v8 binding pattern: import { env } from "cloudflare:workers" in a loader, with no context parameter at all. Queries D1.
/probeThe explicit-context pattern for the two things env cannot give you: ExecutionContext (waitUntil) and request.cf. Also demonstrates middleware ordering around next().
/__probe/plain-objectThe v7 pattern, returning its own 500.
Terminal window
# ctx.waitUntil and request.cf require an explicit RouterContextProvider
curl -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 suffix
curl -s localhost:9026/probe.data | head -c 200
# ..."rawRequestUrl","http://localhost:9026/probe.data"...
# the root data path was renamed in v8
curl -o /dev/null -w "%{http_code}\n" localhost:9026/_root.data # 404
curl -o /dev/null -w "%{http_code}\n" localhost:9026/_.data # 200

That last pair matters on Cloudflare: any Cache Rule, WAF rule, _routes.json, or run_worker_first glob matching .data needs updating.

Terminal window
npx react-router build
cat build/server/wrangler.json

The plugin generates the deploy config for you:

{ "main": "index.js", "assets": { "directory": "../client" }, "no_bundle": true, ... }
  • Do not hand-write an assets block in wrangler.jsonc.
  • Your input config’s main points at TypeScript (./workers/app.ts); the generated config’s main points at the built chunk. Both are correct.
  • .wrangler/deploy/config.json points wrangler deploy at the generated config, so deploy with a bare wrangler deploynever -c wrangler.jsonc.
  • build/client/.assetsignore is emitted automatically containing wrangler.json and .dev.vars.
Terminal window
npm run build && npx wrangler deploy

Two bindings patterns, and when to use which

Section titled “Two bindings patterns, and when to use which”
NeedPattern
KV / D1 / R2 / vars / secretsimport { env } from "cloudflare:workers"
ctx.waitUntil()createContext() + new RouterContextProvider()
request.cfsame, or read request directly in the route
Injecting fakes in testscontext — 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.

  • react-router-dom has no 8.x. Last version is 7.18.2. Import from react-router / react-router/dom.
  • json() and defer() are gone — and the v8 upgrade guide never mentions them, because they were removed during the v7 line. Use data() or return plain objects / Response.
  • @react-router/cloudflare@8.3.0 still publishes, but it is the Cloudflare Pages Functions adapter (PagesFunction<Env>, EventContext). A Workers app should not install it.
  • vite-tsconfig-paths is replaced by Vite’s built-in resolve: { tsconfigPaths: true }.
  • react-router.config.ts has no future block any more — FutureConfig in 8.3.0 contains only unstable_enableNodeReadableStream and unstable_optimizeDeps.

原始碼

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.ts

tsconfig.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
  }
}