React Router v7 on Cloudflare Workers
React Router v7 is Cloudflare’s recommended React framework. It replaced Remix, uses the CF Vite Plugin for full dev/prod parity, and gives you direct access to the Worker entry point for Durable Objects, Queues, and other advanced bindings.
Create a New Project
npm create cloudflare@latest my-react-app -- --framework=react-router
This scaffolds a project with the CF Vite Plugin pre-configured.
Project Structure
my-react-app/
├── app/
│ ├── routes/ # File-based routing
│ │ └── home.tsx # Route with loader/action
│ ├── root.tsx # Root layout
│ └── routes.ts # Route config
├── workers/
│ └── app.ts # Worker entry point
├── vite.config.ts # Vite + CF plugin config
└── wrangler.jsonc # Cloudflare config
Vite 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({ viteEnvironment: { name: "ssr" } }),
reactRouter(),
],
});
Worker Entry Point
The Worker entry point is where you wire up bindings and can add Durable Objects, Queues, or other Worker features alongside your React app.
// workers/app.ts
import { createRequestHandler } from "react-router";
const requestHandler = createRequestHandler(
// @ts-expect-error - virtual module provided by React Router
() => import("virtual:react-router/server-build"),
import.meta.env.MODE
);
export default {
fetch(request: Request, env: Env) {
return requestHandler(request, { cloudflare: { env } });
},
} satisfies ExportedHandler<Env>;
Tip: You can export Durable Objects, scheduled handlers, and queue consumers from this same file. Your React app and Worker features coexist in one deployment.
Route with Data Loading
// app/routes/home.tsx
import type { Route } from "./+types/home";
export async function loader({ context }: Route.LoaderArgs) {
const env = context.cloudflare.env;
const visits = await env.MY_KV.get("visits");
return { visits: Number(visits || 0) };
}
export async function action({ request, context }: Route.ActionArgs) {
const env = context.cloudflare.env;
const current = Number(await env.MY_KV.get("visits") || 0);
await env.MY_KV.put("visits", String(current + 1));
return { ok: true };
}
export default function Home({ loaderData }: Route.ComponentProps) {
return (
<div>
<h1>Visits: {loaderData.visits}</h1>
<form method="post">
<button type="submit">Increment</button>
</form>
</div>
);
}
Wrangler Config
// wrangler.jsonc
{
"name": "my-react-app",
"main": "./workers/app.ts",
"compatibility_flags": ["nodejs_compat"],
"assets": {
"directory": "./build/client"
},
"kv_namespaces": [
{ "binding": "MY_KV", "id": "your-kv-id" }
]
}
Generate Binding Types
npx wrangler types
This creates worker-configuration.d.ts with your Env type based on wrangler.jsonc.
Dev and Deploy
# Development (runs in workerd via CF Vite Plugin)
npm run dev
# Deploy
npx wrangler deploy
Key Points
- Loaders run on the server (in
workerd), actions handle mutations - File-based routing in
app/routes/ context.cloudflare.envgives you typed access to all bindings- The CF Vite Plugin means
npm run devuses the realworkerdruntime - You can colocate Durable Objects and other Worker exports in the entry point
Gotcha: React Router v7’s Cloudflare template uses
workers/app.tsas the entry point, not a default export fromapp/entry.server.tsx. If you’re coming from Remix, the architecture is slightly different.