Back to articles
DevOps
Feb 21, 2026
12 min read

Step-by-Step: Deploying React Router v7 + Hono to Cloudflare Pages

After wrestling with Cloudflare Pages deployments for a full day, I've distilled the entire process into a clean, reproducible guide. Every pitfall documented here is one I hit personally — so you don't have to.

Why This Stack?

React Router v7 gives us Remix-style loaders and actions built on standard Web APIs. Hono gives us an ultra-fast, edge-native HTTP server with middleware, JWT, and cookie support. Cloudflare Pages gives us globally distributed hosting with D1, R2, KV, and Workers AI — all at the edge.

The challenge? Getting all three to play together during the build and deploy process. Here's the exact configuration that works.


Step 1: Initialize the Project

Start with a fresh React Router v7 project:

npx create-react-router@latest my-app
cd my-app

Install the core dependencies:

# Production deps
bun add hono react-router-hono-server drizzle-orm

# Dev deps
bun add -D @cloudflare/workers-types miniflare wrangler

Key insight: react-router-hono-server is NOT a dev dependency. It creates the actual Hono server used in production.


Step 2: Configure Vite

This is where most tutorials fall short. The Vite config must use reactRouterHonoServer with runtime: "cloudflare". This single flag handles all the SSR bundling, module externalization, and worker-compatible output automatically.

// vite.config.ts
import { reactRouter } from "@react-router/dev/vite";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
import { reactRouterHonoServer } from "react-router-hono-server/dev";

export default defineConfig({
  server: { port: 3000 },
  plugins: [
    reactRouterHonoServer({
      runtime: "cloudflare",
      serverEntryPoint: "./server.ts",
    }),
    tailwindcss(),
    reactRouter(),
    tsconfigPaths(),
  ],
});

⚠️ Do NOT use @cloudflare/vite-plugin alongside react-router-hono-server. The Cloudflare Vite plugin only compiles React Router's internal virtual modules — it completely ignores your server.ts Hono entrypoint, meaning your API routes, middleware, and auth logic are silently dropped from the build.


Step 3: Create the Hono Server

Create server.ts at the project root. Import createHonoServer from the Cloudflare adapter:

// server.ts
import { Hono } from "hono";
import { createHonoServer } from "react-router-hono-server/cloudflare";
import { getCookie, setCookie } from "hono/cookie";
import { sign, verify } from "hono/jwt";

type Env = {
  DB: D1Database;
  JWT_SECRET: string;
};

type Variables = {
  user: any;
};

const app = new Hono<{ Bindings: Env; Variables: Variables }>();

// Auth middleware
app.use("*", async (c, next) => {
  const token = getCookie(c, "auth_token");
  if (token) {
    try {
      const payload = await verify(token, c.env.JWT_SECRET, "HS256");
      c.set("user", payload);
    } catch (e) {
      // Invalid token — continue without user
    }
  }
  await next();
});

// Your API routes go here
app.post("/api/auth/login", async (c) => {
  // ... login logic
});

// Export the server — createHonoServer wires up React Router automatically
export default await createHonoServer({
  app,
  getLoadContext(c, options) {
    return {
      cloudflare: {
        env: c.env as any,
        ctx: c.executionCtx,
        user: c.get("user"),
      },
    };
  },
});

What createHonoServer does: It attaches a catch-all route that delegates to React Router's request handler, passing your Cloudflare bindings through getLoadContext. It also correctly exports the fetch handler that Cloudflare Workers expect.


Step 4: Create entry.server.tsx (Critical!)

This is the single most important file for Cloudflare deployment — and the gotcha that will cost you hours if you miss it. Let's understand exactly what it does and why it's needed.

What is entry.server.tsx?

When a user requests a page, React Router calls this file to render your entire React component tree into HTML on the server. It's the bridge between your React components and the raw HTTP Response that gets sent to the browser.

React Router invokes your exported handleRequest function with five arguments:

  • request — the incoming HTTP Request object (standard Web API)
  • responseStatusCode — the initial status code (200, 404, etc.) determined by your loaders
  • responseHeaders — headers to include in the response
  • routerContext — the resolved route tree, loader data, and action results
  • loadContext — your custom context from getLoadContext in server.ts (Cloudflare bindings live here)

Why the default doesn't work on Cloudflare

React Router's default entry server uses renderToPipeableStream from react-dom/server. This function returns a Node.js Readable Stream — a stream type that relies on Node's internal stream module.

Cloudflare Workers run on the V8 isolate runtime, not Node.js. They support the Web Streams API ( ReadableStream, WritableStream) but do NOT support Node.js streams. When the worker tries to call renderToPipeableStream, it throws:

TypeError: (0 , import_server.renderToPipeableStream) is not a function

The fix is to use renderToReadableStream instead, which returns a standard Web ReadableStream that Cloudflare Workers natively support.

The implementation

Create app/entry.server.tsx:

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

export default async function handleRequest(
  request: Request,
  responseStatusCode: number,
  responseHeaders: Headers,
  routerContext: EntryContext,
  _loadContext: AppLoadContext
) {
  const userAgent = request.headers.get("user-agent");
  const body = await renderToReadableStream(
    <ServerRouter context={routerContext} url={request.url} />,
    {
      signal: request.signal,
      onError(error: unknown) {
        console.error(error);
        responseStatusCode = 500;
      },
    }
  );

  if (isbot(userAgent || "")) {
    await body.allReady;
  }

  responseHeaders.set("Content-Type", "text/html");

  return new Response(body, {
    headers: responseHeaders,
    status: responseStatusCode,
  });
}

Line-by-line breakdown

renderToReadableStream — renders the <ServerRouter> component tree into a ReadableStream. This is the Web Streams equivalent of Node's renderToPipeableStream. It starts streaming HTML chunks immediately as Suspense boundaries resolve.

signal: request.signal — connects the render lifecycle to the request's abort signal. If the client disconnects mid-render, the stream is automatically cancelled, freeing up CPU time on the worker.

onError — catches any rendering errors (like a loader throwing, or a component crashing during SSR). It logs the error and upgrades the response to a 500 status code.

isbot() check — this is the SEO-critical part. For regular users, the response starts streaming immediately (fast TTFB). But for search engine crawlers (Googlebot, Bingbot, etc.), await body.allReady waits for the entire page to finish rendering before sending any bytes. This ensures crawlers see the complete HTML content for proper indexing, while real users get the fastest possible response.

new Response(body, ...) — wraps the ReadableStream into a standard Web API Response object. This is what Cloudflare Workers' fetch handler returns to the edge network.

Without this file, you will see TypeError: renderToPipeableStream is not a function in your Cloudflare deployment logs and a blank "Unexpected Server Error" page. Every non-Node.js deployment target (Cloudflare, Deno, Bun Workers) requires this file.


Step 5: Configure react-router.config.ts

Enable the Vite Environment API for proper Cloudflare Workers support:

// react-router.config.ts
import type { Config } from "@react-router/dev/config";

export default {
  ssr: true,
  future: {
    v8_viteEnvironmentApi: true,
  },
} satisfies Config;

Step 6: Configure wrangler.toml

Create wrangler.toml at the project root. The key setting is pages_build_output_dir — without it, Cloudflare Pages ignores the entire configuration file (including your D1/KV/R2 bindings).

name = "my-app"
compatibility_date = "2024-09-23"
compatibility_flags = ["nodejs_compat"]
pages_build_output_dir = "./build/client"

# D1 Database
[[d1_databases]]
binding = "DB"
database_name = "my-db"
database_id = "your-database-id-here"

# KV Namespace
[[kv_namespaces]]
binding = "CACHE"
id = "your-kv-namespace-id"

# R2 Bucket
[[r2_buckets]]
binding = "MEDIA"
bucket_name = "my-media-bucket"

Important: Set compatibility_date to "2024-09-23" or later. This enables native node: import resolution, which React DOM and Hono depend on.


Step 7: Set Up the Build Script

The react-router build command outputs to build/client/ and build/server/. Cloudflare Pages expects a _worker.js file inside the client output directory.

{
  "scripts": {
    "build": "react-router build && cp build/server/index.js build/client/_worker.js",
    "dev": "react-router dev",
    "start": "wrangler dev"
  }
}

If the @cloudflare/vite-plugin auto-generates a wrangler.json inside build/client/, it will override your wrangler.toml and break the deploy. Add cleanup:

{
  "scripts": {
    "build": "react-router build && rm -rf build/client/wrangler.json .wrangler/deploy/config.json && cp build/server/index.js build/client/_worker.js"
  }
}

Step 8: Add .gitignore Entries

Make sure the Wrangler cache and build artifacts are excluded from version control:

# Add to .gitignore
/.wrangler/
/build/

Step 9: Remove package-lock.json

If you're using bun as your package manager, delete package-lock.json. Cloudflare Pages detects it and runs npm ci instead of bun install, which skips devDependencies and causes react-router: command not found errors during the build.

rm package-lock.json

Step 10: Deploy

Build and deploy with a single command:

bun run build
bunx wrangler pages deploy build/client --commit-dirty=true

You should see output like:

✨ Compiled Worker successfully
✨ Uploading Worker bundle
🌎 Deploying...
✨ Deployment complete! Take a peek over at https://abc123.my-app.pages.dev

Common Errors & Solutions

UNRESOLVED_IMPORT — bare module imports in _worker.js

Cause: Vite left import "hono" as an external dependency instead of bundling it.

Fix: Use runtime: "cloudflare" in the reactRouterHonoServer plugin config. This forces Vite to bundle all dependencies into the worker.

renderToPipeableStream is not a function

Cause: Using React Router's default Node.js entry server in a Workers environment.

Fix: Create app/entry.server.tsx with renderToReadableStream as shown in Step 4.

No such module "assets/hono"

Cause: Dependencies were externalized into separate chunks that Cloudflare can't resolve.

Fix: Ensure the Vite plugin uses runtime: "cloudflare" to produce a single self-contained bundle.

wrangler.json overriding wrangler.toml

Cause: The @cloudflare/vite-plugin auto-generates a wrangler.json that takes precedence.

Fix: Remove it in the build script: rm -rf build/client/wrangler.json.

react-router: command not found on Cloudflare Pages CI

Cause: package-lock.json triggers npm ci which skips devDependencies.

Fix: Delete package-lock.json so Cloudflare uses bun install instead.


Final File Structure

my-app/
├── app/
│   ├── entry.server.tsx    ← Web Streams (renderToReadableStream)
│   ├── root.tsx
│   └── routes/
├── server.ts               ← Hono server with createHonoServer()
├── vite.config.ts           ← reactRouterHonoServer({ runtime: "cloudflare" })
├── wrangler.toml            ← pages_build_output_dir + bindings
├── react-router.config.ts   ← v8_viteEnvironmentApi: true
└── package.json             ← build script with _worker.js copy

This configuration gives you a fully server-rendered React app with a custom Hono API layer, running globally on Cloudflare's edge network with native access to D1, R2, KV, and Workers AI. Zero cold starts. Sub-50ms responses worldwide.

Sponsored

Build your next project on Cloudflare

Deploy full-stack apps globally with Workers, D1, and R2. Start for free.

Try Cloudflare Workers
0 views

Buy me a coffee

If this article helped you, consider supporting my work. Every coffee fuels the next deep-dive!

Stay in the loop

Get notified when I publish new articles on TypeScript, React, Node.js, and fullstack architecture. No spam, ever.