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.env gives you typed access to all bindings
  • The CF Vite Plugin means npm run dev uses the real workerd runtime
  • You can colocate Durable Objects and other Worker exports in the entry point

Gotcha: React Router v7’s Cloudflare template uses workers/app.ts as the entry point, not a default export from app/entry.server.tsx. If you’re coming from Remix, the architecture is slightly different.