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-pluginalongsidereact-router-hono-server. The Cloudflare Vite plugin only compiles React Router's internal virtual modules — it completely ignores yourserver.tsHono 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 loadersresponseHeaders— headers to include in the responserouterContext— the resolved route tree, loader data, and action resultsloadContext— your custom context fromgetLoadContextin 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 functionin 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.
Build your next project on Cloudflare
Deploy full-stack apps globally with Workers, D1, and R2. Start for free.
Try Cloudflare Workers →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.