Run a React Router framework-mode application on Hono—without giving up the runtime you want.
react-router-hono-server provides a Vite plugin and production adapters for Node.js, Bun, Deno, Cloudflare Workers, and AWS Lambda.
It gives your application one clear server boundary:
- React Router owns routes, loaders, actions, and rendering.
- Hono owns middleware, API routes, request context, and runtime integration.
- This package connects them and handles startup, static assets, and build output.
- One server API across five runtimes. Move between runtimes without redesigning the application.
- First-class Hono customization. Add middleware, API endpoints, typed context, and runtime options around React Router.
- Production and development parity. Use the same server entry in Vite development and in the production runtime.
- React Router features stay intact. SSR, prerendering, SPA output, basenames, and custom build layouts remain React Router configuration.
- Small default surface. Start with a virtual server entry and reveal files only when customization is needed.
- Runtime matrix
- Requirements and compatibility
- Minimal Node quick start
- Runtime selection
- Reveal and entry files
- Runtime guides
- Server customization
- API and exports
- Troubleshooting
| Runtime | Development | Production | WebSockets | Static assets |
|---|---|---|---|---|
| Node.js | React Router dev server | Node HTTP/HTTPS | Yes | Node filesystem |
| Bun | Bun-powered React Router dev server | Bun.serve |
Yes | Bun filesystem |
| Deno | Deno-powered Vite dev server | Deno.serve |
Yes | Deno filesystem |
| Cloudflare Workers | Cloudflare Vite plugin and Workerd | Worker + asset binding | Yes | Workers assets |
| AWS Lambda | React Router dev server | Lambda handler or response streaming | No | Use CloudFront/S3 in production |
Choose the runtime that matches your deployment target. Application routes and Hono configuration remain portable; runtime-specific server options stay isolated in app/server.ts.
- Node.js 24.19 or newer is required for installation, builds, and the CLI.
- React 19.2, React DOM 19.2, React Router 8.3 or newer, Vite 8, and Hono 4 are supported.
@hono/node-server2 is installed with this package for Node-backed functionality; applications only need to install it directly when they import it themselves.- Bun 1.4 or newer is required for Bun execution.
- Deno 2 is required for Deno execution.
- Cloudflare projects require the current
@cloudflare/vite-plugin, Wrangler 4, anASSETSbinding, and thenodejs_compatcompatibility flag.
Important
Keep the application and this package on one installation of React, React DOM, React Router, Hono, and Vite.
Aliased or duplicated framework packages can cause invalid hooks, incompatible contexts, and build failures.
This guide assumes you already have the default React Router framework-mode project. This package adds a Hono server to that application; it does not scaffold the React Router application itself.
The install commands below are changes to that default project. Keep its existing @react-router/dev and vite packages for every runtime. Keep @react-router/node for Node and AWS, but remove it for Bun, Deno, and Cloudflare so React Router selects its Web Streams renderer. This package replaces @react-router/serve on every runtime.
The following setup creates a Node.js server. The other runtime guides use the same structure with a different adapter.
pnpm remove @react-router/serve
pnpm add react-router-hono-server honoCreate vite.config.ts. The Hono server plugin must come before reactRouter().
import { reactRouter } from "@react-router/dev/vite";
import { reactRouterHonoServer } from "react-router-hono-server/dev";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [reactRouterHonoServer(), reactRouter()],
});Create app/server.ts:
import { createHonoServer } from "react-router-hono-server/node";
export default await createHonoServer();Use these scripts in package.json:
{
"scripts": {
"build": "react-router build",
"dev": "react-router dev",
"start": "node ./build/server/index.js",
"typecheck": "react-router typegen && tsc --noEmit"
}
}# Development
pnpm dev
# Production
pnpm build
pnpm startThe runtime is selected in two places:
- Set
runtimeinreactRouterHonoServer(). - Import the matching adapter from the server entry.
| Runtime | Plugin option | Server import |
|---|---|---|
| Node.js | omitted or node |
react-router-hono-server/node |
| Bun | bun |
react-router-hono-server/bun |
| Deno | deno |
react-router-hono-server/deno |
| Cloudflare | cloudflare |
react-router-hono-server/cloudflare |
| AWS Lambda | aws |
react-router-hono-server/aws-lambda |
Important
The plugin must precede reactRouter(). On Cloudflare, cloudflare() must precede both plugins.
React Router and this package each provide a default entry file. These entries operate at different layers and solve different problems.
| Entry | Owned by | Responsibility |
|---|---|---|
app/server.ts |
react-router-hono-server |
Hono middleware, API routes, load context, assets, and runtime startup |
app/entry.server.tsx |
React Router | Rendering a matched React Router request into a response |
app/entry.client.tsx |
React Router | Browser hydration |
Reveal only the entry you need to customize.
Without app/server.ts or app/server/index.ts, the plugin supplies a virtual Hono server with default options.
Reveal the Hono entry when you need any of the following:
- Hono middleware or API routes
- A React Router load context
- WebSockets
- Static-file configuration
- Runtime-specific server options
Create app/server.ts:
npx react-router-hono-server reveal fileUse the folder form when the server has colocated modules:
npx react-router-hono-server reveal folderThe CLI infers the runtime from vite.config.ts. If it cannot find a runtime option, it generates a Node.js entry.
Warning
Run the command from the project root. The reveal command overwrites its target, so do not run it over an entry you have already customized.
React Router supplies hidden client and server rendering entries. It selects the server renderer from the application's dependencies:
- Applications with
@react-router/node,@react-router/express, or@react-router/serveuse the Node streaming entry. - Applications without those packages use the Web Streams entry.
- A custom
app/entry.server.tsxalways takes precedence.
Reveal the entries only when the application needs custom hydration or SSR behavior:
npx react-router revealThe command generates both app/entry.client.tsx and app/entry.server.tsx.
See the React Router reveal documentation for the generated files.
React Router 8.3 has no stable future flags. Its two unstable flags are optional and belong in react-router.config.ts; reactRouterHonoServer() does not duplicate or enable them:
import type { Config } from "@react-router/dev/config";
export default {
future: {
unstable_enableNodeReadableStream: true,
unstable_optimizeDeps: true,
},
} satisfies Config;unstable_enableNodeReadableStreammakes React Router use its Web Streams default entry on Node. It has no effect whenapp/entry.server.tsxexists.unstable_optimizeDepsadds the client entry and route modules to Vite's dependency optimizer in development. If it causes optimization issues, remove the flag and restart the dev server.
These flags are experimental and may change in React Router minor releases. See React Router's future changes guide and changelog.
Node.js is the default runtime and the shortest path to production. Follow the minimal Node quick start; no runtime option is required.
- React Router's default Node rendering entry is compatible.
- Set the listening port with
PORTor theportserver option. - Use
hostnameto control the listening interface. - Advanced options include
listeningListener,onServe,customNodeServer, andoverrideGlobalObjects. - Static-file customization and WebSockets are supported.
bun remove @react-router/node @react-router/serve
bun add react-router-hono-server hono
bun add -d @types/bunCreate vite.config.ts and select the Bun runtime:
import { reactRouter } from "@react-router/dev/vite";
import { reactRouterHonoServer } from "react-router-hono-server/dev";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [reactRouterHonoServer({ runtime: "bun" }), reactRouter()],
});Create app/server.ts:
import { createHonoServer } from "react-router-hono-server/bun";
export default await createHonoServer();{
"scripts": {
"build": "bun x --bun react-router build",
"dev": "bun x --bun vite",
"start": "bun ./build/server/index.js",
"typecheck": "react-router typegen && tsc --noEmit"
}
}# Development
bun run dev
# Production
bun run build
bun run startbun x --bun react-router buildruns React Router and prerendering under Bun instead of following the CLI's Node.js shebang.bun x --bun viteforces Vite and its child processes to run with Bun.- React Router automatically uses its Web Streams server entry.
customBunServerforwards options toBun.serve.- Graceful shutdown, static-file customization, and WebSockets are supported.
In package.json, remove @react-router/node and @react-router/serve, add react-router-hono-server and hono to dependencies, then install with Deno:
deno install --allow-scripts --minimum-dependency-age=0Create vite.config.ts and select the Deno runtime:
import { reactRouter } from "@react-router/dev/vite";
import { reactRouterHonoServer } from "react-router-hono-server/dev";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [reactRouterHonoServer({ runtime: "deno" }), reactRouter()],
});Create app/server.ts:
import { createHonoServer } from "react-router-hono-server/deno";
export default await createHonoServer();{
"scripts": {
"build": "react-router build",
"dev": "deno run --conditions=development --allow-all npm:@react-router/dev dev",
"start": "deno run --allow-all ./build/server/index.js",
"typecheck": "react-router typegen && tsc --noEmit"
}
}# Development
deno task dev
# Production
deno task build
deno task start- The development command enables the
developmentexport condition required by React Router. customDenoServerforwards options toDeno.serve.- Graceful shutdown and static-file customization are supported.
- WebSockets use the optional
wspeer in Vite development and Deno's native implementation in production.
pnpm remove @react-router/node @react-router/serve
pnpm add react-router-hono-server hono
pnpm add -D @cloudflare/vite-plugin @cloudflare/workers-types wranglerCreate vite.config.ts. Plugin order is required:
import { cloudflare } from "@cloudflare/vite-plugin";
import { reactRouter } from "@react-router/dev/vite";
import { reactRouterHonoServer } from "react-router-hono-server/dev";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [
cloudflare({ viteEnvironment: { name: "ssr" } }),
reactRouterHonoServer({ runtime: "cloudflare" }),
reactRouter(),
],
});Create app/server.ts:
import { createHonoServer } from "react-router-hono-server/cloudflare";
export default await createHonoServer();Create wrangler.jsonc:
The ASSETS binding connects the generated client directory to the Worker.
{
"scripts": {
"build": "react-router build",
"dev": "vite dev",
"start": "vite preview",
"typecheck": "react-router typegen && tsc --noEmit"
}
}# Workerd-backed development
pnpm dev
# Local production preview
pnpm build
pnpm startDeploy the generated Worker with your normal Cloudflare workflow.
- Public files and generated client files are served through
ASSETS. - Missing or unsuccessful asset responses fall through to Hono and React Router.
- Prerendering and SPA mode are supported.
- With
ssr: true, a route without a generated asset falls through to runtime SSR. - WebSockets use Cloudflare's native
WebSocketPairimplementation in both workerd-backed development and production.
pnpm remove @react-router/serve
pnpm add react-router-hono-server honoCreate vite.config.ts and select the AWS runtime:
import { reactRouter } from "@react-router/dev/vite";
import { reactRouterHonoServer } from "react-router-hono-server/dev";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [reactRouterHonoServer({ runtime: "aws" }), reactRouter()],
});Create app/server.ts for the default response mode:
import { createHonoServer } from "react-router-hono-server/aws-lambda";
export default await createHonoServer({ invokeMode: "default" });Set invokeMode: "stream" to use Lambda response streaming.
React Router's default Node rendering entry is compatible. Reveal it only when the application needs custom SSR behavior.
{
"scripts": {
"build": "react-router build",
"dev": "react-router dev",
"typecheck": "react-router typegen && tsc --noEmit"
}
}Use pnpm dev locally, then run pnpm build before packaging build/server for Lambda.
Your infrastructure must expose the generated default handler as the Lambda entry.
- Production static files should be served by S3, CloudFront, or another asset service.
- Prerendered files are generated in
build/client; deploy them with the static assets. - Requests that reach Lambda continue through runtime SSR.
- Both default responses and Lambda response streaming are supported.
Create or reveal a Hono server entry before using these options:
npx react-router-hono-server reveal filePass app to use an existing Hono instance. Hooks execute in this order:
| Order | Hook or middleware | Typical use |
|---|---|---|
| 1 | beforeAll(app) |
Authentication or request policy that must run before assets |
| 2 | Built-in asset handling | Public files and generated client assets |
| 3 | Built-in logger | Request logging when defaultLogger is enabled |
| 4 | configure(app) |
API routes and application middleware |
| 5 | React Router handler | Loaders, actions, and rendered routes |
import { Hono } from "hono";
import { createHonoServer } from "react-router-hono-server/node";
const app = new Hono();
export default await createHonoServer({
app,
defaultLogger: false,
beforeAll(server) {
server.use("/private/*", async (c, next) => {
if (!c.req.header("authorization")) return c.text("Unauthorized", 401);
await next();
});
},
configure(server) {
server.get("/api/health", (c) => c.json({ ok: true }));
},
});To let Chrome DevTools automatically connect to your project, create
public/.well-known/appspecific/com.chrome.devtools.json with your project's absolute path and a stable UUID v4:
{
"workspace": {
"root": "/absolute/path/to/your/project",
"uuid": "53b029bb-c989-4dca-969b-835fecec3717"
}
}The development server serves this file before the React Router handler. When the file is absent, the discovery request
returns a quiet 404. See Automatic Workspace connection in Chrome DevTools
for setup details and security considerations.
React Router 8 always expects getLoadContext to return a RouterContextProvider.
import { createContext, RouterContextProvider } from "react-router";
import { createHonoServer } from "react-router-hono-server/node";
export const requestIdContext = createContext<string>();
export default await createHonoServer({
getLoadContext(c) {
const context = new RouterContextProvider();
context.set(requestIdContext, c.req.header("x-request-id") ?? crypto.randomUUID());
return context;
},
});Each adapter also exports createGetLoadContext for separately declared callbacks that need contextual typing.
Node, Bun, Deno, and Cloudflare Workers support the Hono WebSocket helper in development and production.
Note
Node always uses @hono/node-server.
Bun and Deno use it only while running through Vite in development.
Install the optional ws peer and its TypeScript declarations for those modes.
Bun and Deno production builds use their native Hono adapters.
They do not load ws in production.
Cloudflare uses its native WebSocketPair implementation in workerd-backed
Vite development and production, so it never requires ws.
pnpm add ws
pnpm add -D @types/wsThe portable callback API exposes upgradeWebSocket:
import { createHonoServer } from "react-router-hono-server/deno";
export default await createHonoServer({
useWebSocket: true,
configure(app, { upgradeWebSocket }) {
app.get(
"/ws",
upgradeWebSocket(() => ({
onMessage(event, ws) {
ws.send(`echo:${event.data}`);
},
})),
);
},
});Use the matching /bun, /deno, or /cloudflare adapter import.
Cloudflare's Hono helper does not support onOpen.
Use onMessage, onClose, and onError there.
The Node adapter exposes its underlying ws.WebSocketServer as wss:
import { createHonoServer } from "react-router-hono-server/node";
export default await createHonoServer({
useWebSocket: true,
configure(app, { upgradeWebSocket, wss }) {
wss.on("connection", (socket) => {
socket.send("connected");
});
app.get(
"/ws",
upgradeWebSocket(() => ({
onMessage(event, ws) {
ws.send(`echo:${event.data}`);
},
})),
);
},
});wss is Node-only because it is the concrete server created by the ws package.
Bun, Deno, and Cloudflare use runtime-native, per-connection WebSocket APIs
instead of an equivalent central WebSocketServer.
The temporary wss used by Bun and Deno during Vite development is not exposed.
Doing so would provide an API that disappears in production.
Node WebSockets use @hono/node-server 2 and coexist with Vite HMR.
Use wss.on("connection"), wss.clients, and the connection sockets' ping()
method for server-level connection management and heartbeat handling.
Bun, Deno, and Cloudflare expose only upgradeWebSocket.
Prerendering is configured by React Router in react-router.config.ts.
import type { Config } from "@react-router/dev/config";
export default {
prerender: true,
} satisfies Config;Dynamic routes are not included because their parameter values are unknown.
import type { Config } from "@react-router/dev/config";
export default {
prerender: ["/", "/about", "/posts/launch"],
} satisfies Config;Use concrete paths such as /posts/launch for dynamic routes. Paths are relative to the React Router basename; do not include the basename itself.
import type { Config } from "@react-router/dev/config";
export default {
prerender: {
async paths() {
return ["/", "/about"];
},
concurrency: 4,
},
} satisfies Config;React Router also accepts basename, appDirectory, and buildDirectory in the same configuration file.
React Router's basename and Vite's base configure independent URL spaces:
basenamemounts documents, route data requests, and the React Router handler.basecontrols Vite development URLs and URLs emitted for generated JavaScript, CSS, and other bundled assets.
For an application deployed entirely beneath /v2, configure both upstream tools:
// react-router.config.ts
export default { basename: "/v2" };
// vite.config.ts
export default defineConfig({ base: "/v2/", plugins: [reactRouterHonoServer(), reactRouter()] });The prefixes may intentionally differ. For example, basename: "/v2/app" with base: "/v2/" keeps application documents beneath /v2/app while generated assets remain beneath /v2/assets. Likewise, basename: "/" with base: "/v2/" keeps documents at the origin root and moves only Vite-owned URLs.
The Node, Bun, and Deno adapters serve generated assets locally when base is an absolute pathname. Cloudflare Workers passes the original asset URL to its asset binding, and AWS production expects an external asset service. Full-URL bases remain externally owned. Relative bases ("" and "./") are preserved as emitted by Vite and do not create a fixed server-side mount. Public-directory files always remain available from the origin root rather than beneath base.
| Configuration | Node, Bun, Deno, and Cloudflare Workers | AWS Lambda |
|---|---|---|
ssr: true with prerender |
Generates and serves static documents and route data. Unmatched paths use runtime SSR. |
Generates the same static output, but a separate AWS asset service must serve it. Unmatched Lambda requests use SSR. |
ssr: false |
Generates static output and an SPA fallback for static hosting | Generates static output and an SPA fallback for static hosting |
- With
ssr: true, generated files are used first and unmatched routes continue to runtime SSR. - With
ssr: false, React Router emits static output and an SPA fallback for static hosting. - AWS generates the same client output, but a separate asset service must serve it before requests reach Lambda.
The integration suite covers static and dynamic paths, async discovery, concurrency, SSR and SPA fallbacks, basenames, and custom application/build directories on every adapter.
See React Router's pre-rendering guide for the full configuration contract.
The plugin reads React Router's resolved configuration and mounts the Hono-backed handler at the same basename.
| Export | Purpose |
|---|---|
react-router-hono-server/dev |
reactRouterHonoServer(options) Vite plugin |
/node |
Node createHonoServer, options, and createGetLoadContext |
/bun |
Bun createHonoServer, options, and createGetLoadContext |
/deno |
Deno createHonoServer, options, and createGetLoadContext |
/cloudflare |
Cloudflare createHonoServer, options, and createGetLoadContext |
/aws-lambda |
AWS handler factory, options, and createGetLoadContext |
/middleware |
cache(seconds) static-response middleware |
/http |
redirect(c, location), deprecated reactRouterRedirect(location), and getPath(c) |
| CLI | react-router-hono-server reveal file or reveal folder |
| Option | Purpose |
|---|---|
runtime |
Selects the production adapter; defaults to node |
serverEntryPoint |
Overrides discovery of app/server.ts or app/server/index.ts |
dev.exclude |
Extends the paths excluded from Hono dev-server handling |
dev.export |
Selects a named export from the server entry during development |
When no server entry is discovered, the plugin supplies a virtual default server for the selected runtime.
| Problem | Resolution |
|---|---|
| Cloudflare plugin is missing | Add cloudflare() before both reactRouterHonoServer() and reactRouter() |
| Invalid hook calls or incompatible contexts | Remove aliases and duplicate framework installations, then perform one clean install |
| Server entry is not discovered | Create app/server.ts, create app/server/index.ts, or set serverEntryPoint |
| Load context fails at runtime | Return a RouterContextProvider, not a plain object |
| An asset request returns application HTML | Verify buildDirectory; on Cloudflare, also verify the ASSETS binding |
| Upgrading from the previous major | Follow MIGRATION.md and complete its clean-install checklist |
{ "$schema": "./node_modules/wrangler/config-schema.json", "name": "my-react-router-worker", "compatibility_date": "2026-08-11", "compatibility_flags": ["nodejs_compat"], "main": "./app/server.ts", "assets": { "directory": "./build/client", "binding": "ASSETS", }, }