Deploying an existing React Router app to Cloudflare Workers

This project highlights how to get an existing (Node.js or otherwise) React Router project deployed on Cloudflare Workers.

(If instead you want to start a project from scratch, the easiest way is by following the framework guide on the Cloudflare Docs site)

This example uses the completed Address Book tutorial as the project we want to deploy to Cloudflare. You can view the code at that point here.

If you want the TLDR, you can see this commit for the additions outlined here to deploy it on Cloudflare.

Install steps

Firstly we'll install the wrangler CLI tool to manage Workers and other Cloudflare products, as well as the Cloudflare Vite plugin.

npm install -D wrangler @cloudflare/vite-plugin

We can also remove some packages we'll no longer need (this step is optional)

npm rm @react-router/node @react-router/serve cross-env

Modify config

react-router.config.ts

Enable support for the Vite Environment API in react-router.config.ts:

  export default {
    ssr: true,
    prerender: ["/about"],
+   future: {
+     unstable_viteEnvironmentApi: true,
+   },
  } satisfies Config;

vite.config.ts

And add the Cloudflare Vite plugin to vite.config.ts:

  import { reactRouter } from "@react-router/dev/vite";
+ import { cloudflare } from "@cloudflare/vite-plugin";
  import { defineConfig } from "vite";

  export default defineConfig({
-   plugins: [reactRouter()],
+   plugins: [cloudflare({ viteEnvironment: { name: "ssr" } }), reactRouter()],

Note that because the Address Book tutorial uses Server-Side Rendering (ssr: true), we've specified to use the ssr Vite environment here.

Add Workers-specific config

wrangler.jsonc

Any Workers-specific configuration, such as the name of our Worker, bindings to other products or observability features, are specified in wrangler.jsonc:

{
  "$schema": "./node_modules/wrangler/config-schema.json",
  "name": "react-router-address-book",
  "compatibility_date": "2025-04-04",
  "main": "./workers/app.ts",
  "observability": {
    "enabled": true
  }
}

Add Worker entrypoint files

workers/app.ts

The entrypoint for the Worker script is our request handler, referred to from the main field in our wrangler.jsonc above.

mkdir workers

And then create workers/app.ts with this:

import { createRequestHandler } from "react-router";

declare module "react-router" {
  export interface AppLoadContext {
    cloudflare: {
      env: Env;
      ctx: ExecutionContext;
    };
  }
}

const requestHandler = createRequestHandler(
  () => import("virtual:react-router/server-build"),
  import.meta.env.MODE
);

export default {
  async fetch(request, env, ctx) {
    return requestHandler(request, {
      cloudflare: { env, ctx },
    });
  },
} satisfies ExportedHandler<Env>;

You can find the latest version of this file in the official React Router Cloudflare template.

app/entry.server.tsx

Similarly we'll need an entry.server.tsx tailored to a non-Node.js runtime. Create this file in app/entry.server.tsx:

import type { AppLoadContext, EntryContext } from "react-router";
import { ServerRouter } from "react-router";
import { isbot } from "isbot";
import { renderToReadableStream } from "react-dom/server";

export default async function handleRequest(
  request: Request,
  responseStatusCode: number,
  responseHeaders: Headers,
  routerContext: EntryContext,
  _loadContext: AppLoadContext
) {
  let shellRendered = false;
  const userAgent = request.headers.get("user-agent");

  const body = await renderToReadableStream(
    <ServerRouter context={routerContext} url={request.url} />,
    {
      onError(error: unknown) {
        responseStatusCode = 500;
        // Log streaming rendering errors from inside the shell.  Don't log
        // errors encountered during initial shell rendering since they'll
        // reject and get logged in handleDocumentRequest.
        if (shellRendered) {
          console.error(error);
        }
      },
    }
  );
  shellRendered = true;

  // Ensure requests from bots and SPA Mode renders wait for all content to load before responding
  // https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation
  if ((userAgent && isbot(userAgent)) || routerContext.isSpaMode) {
    await body.allReady;
  }

  responseHeaders.set("Content-Type", "text/html");
  return new Response(body, {
    headers: responseHeaders,
    status: responseStatusCode,
  });
}

You can also find the latest version of this file in the official React Router Cloudflare template.

Extra bits

Ensure the .wrangler directory won't be committed, via your .gitconfig:

  # React Router
  /.react-router/
  /build/
+ /.wrangler

Add some new commands for previewing and deploying in package.json:

   "scripts": {
-    "build": "cross-env NODE_ENV=production react-router build",
+    "build": "react-router build",
     "dev": "react-router dev",
-    "start": "cross-env NODE_ENV=production react-router-serve ./build/server/index.js",
-    "typecheck": "react-router typegen && tsc"
+    "preview": "npm run build && vite preview",
+    "deploy": "npm run build && wrangler deploy",
+    "typegen": "wrangler types && react-router typegen",
+    "typecheck": "npm run typegen && tsc"
   },

Generate types:

npm run typegen

(this will create a worker-configuration.d.ts file with type information)

And you're all done!

Dev, Preview and Deploy

Development mode is the quickest way to develop locally:

npm run dev

Preview mode will do a full production build and run vite preview:

npm run preview

And then to deploy your project to Workers:

npm run deploy

Hooray, it's now live!

Accessing Cloudflare products

You can access bindings to other products like Workers AI, D1, R2, etc via the context.cloudflare.env property on loader/action functions.

For example, let's say we add an environment variable binding to our wrangler.jsonc:

   "main": "./workers/app.ts",
+  "vars": {
+    "ABOUT_LINK_TITLE": "React Router Contacts"
+  },
   "observability": {

We can then access that, say, from the loader in app/layouts/sidebar.tsx:

  import { getContacts } from "../data";
  import type { Route } from "./+types/sidebar";

- export async function loader({ request }: Route.LoaderArgs) {
+ export async function loader({ request, context }: Route.LoaderArgs) {
    const url = new URL(request.url);
    const q = url.searchParams.get("q");
    const contacts = await getContacts(q);
-   return { contacts, q };
+   const linkTitle = context.cloudflare.env.ABOUT_LINK_TITLE;
+   return { contacts, q, linkTitle };
  }

  export default function SidebarLayout({ loaderData }: Route.ComponentProps) {
-   const { contacts, q } = loaderData;
+   const { contacts, q, linkTitle } = loaderData;
    const navigation = useNavigation();
    const submit = useSubmit();
    const searching =

// ...

        <div id="sidebar">
          <h1>
-           <Link to="about">React Router Contacts</Link>
+           <Link to="about">{linkTitle}</Link>
          </h1>
          <div>
            <Form

You can see that modifying that variable in wrangler.jsonc will change the link title – live in dev mode too.

Similarly if you added a binding to Workers AI, you could access that from context.cloudflare.env.AI, etc.