Skip to content

Repository files navigation

React Router Hono Server

Latest compatible runtimes

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.

Why use it?

  • 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.

Contents

Runtime matrix

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.

Requirements and compatibility

Supported versions

  • 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-server 2 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, an ASSETS binding, and the nodejs_compat compatibility 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.

Minimal Node quick start

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.

1. Install the packages

pnpm remove @react-router/serve
pnpm add react-router-hono-server hono

2. Add the Vite plugin

Create 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()],
});

3. Create the server entry

Create app/server.ts:

import { createHonoServer } from "react-router-hono-server/node";

export default await createHonoServer();

4. Add package scripts

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"
  }
}

5. Run the application

# Development
pnpm dev

# Production
pnpm build
pnpm start

Runtime selection

The runtime is selected in two places:

  1. Set runtime in reactRouterHonoServer().
  2. 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.

Reveal and entry files

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.

Hono server entry

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 file

Use the folder form when the server has colocated modules:

npx react-router-hono-server reveal folder

The 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 rendering entries

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/serve use the Node streaming entry.
  • Applications without those packages use the Web Streams entry.
  • A custom app/entry.server.tsx always takes precedence.

Reveal the entries only when the application needs custom hydration or SSR behavior:

npx react-router reveal

The command generates both app/entry.client.tsx and app/entry.server.tsx.

See the React Router reveal documentation for the generated files.

Optional React Router future flags

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_enableNodeReadableStream makes React Router use its Web Streams default entry on Node. It has no effect when app/entry.server.tsx exists.
  • unstable_optimizeDeps adds 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.

Runtime guides

Node.js

Node.js is the default runtime and the shortest path to production. Follow the minimal Node quick start; no runtime option is required.

Node.js runtime notes

  • React Router's default Node rendering entry is compatible.
  • Set the listening port with PORT or the port server option.
  • Use hostname to control the listening interface.
  • Advanced options include listeningListener, onServe, customNodeServer, and overrideGlobalObjects.
  • Static-file customization and WebSockets are supported.

Bun

Install

bun remove @react-router/node @react-router/serve
bun add react-router-hono-server hono
bun add -d @types/bun

Configure Vite

Create 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 the server entry

Create app/server.ts:

import { createHonoServer } from "react-router-hono-server/bun";

export default await createHonoServer();

Add scripts

{
  "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"
  }
}

Run

# Development
bun run dev

# Production
bun run build
bun run start

Bun runtime notes

  • bun x --bun react-router build runs React Router and prerendering under Bun instead of following the CLI's Node.js shebang.
  • bun x --bun vite forces Vite and its child processes to run with Bun.
  • React Router automatically uses its Web Streams server entry.
  • customBunServer forwards options to Bun.serve.
  • Graceful shutdown, static-file customization, and WebSockets are supported.

Deno

Install

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=0

Configure Vite

Create 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 the server entry

Create app/server.ts:

import { createHonoServer } from "react-router-hono-server/deno";

export default await createHonoServer();

Add scripts

{
  "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"
  }
}

Run

# Development
deno task dev

# Production
deno task build
deno task start

Deno runtime notes

  • The development command enables the development export condition required by React Router.
  • customDenoServer forwards options to Deno.serve.
  • Graceful shutdown and static-file customization are supported.
  • WebSockets use the optional ws peer in Vite development and Deno's native implementation in production.

Cloudflare Workers

Install

pnpm remove @react-router/node @react-router/serve
pnpm add react-router-hono-server hono
pnpm add -D @cloudflare/vite-plugin @cloudflare/workers-types wrangler

Configure Vite

Create 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 the server entry

Create app/server.ts:

import { createHonoServer } from "react-router-hono-server/cloudflare";

export default await createHonoServer();

Configure Wrangler

Create wrangler.jsonc:

{
  "$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",
  },
}

The ASSETS binding connects the generated client directory to the Worker.

Add scripts

{
  "scripts": {
    "build": "react-router build",
    "dev": "vite dev",
    "start": "vite preview",
    "typecheck": "react-router typegen && tsc --noEmit"
  }
}

Run

# Workerd-backed development
pnpm dev

# Local production preview
pnpm build
pnpm start

Deploy the generated Worker with your normal Cloudflare workflow.

Cloudflare runtime notes

  • 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 WebSocketPair implementation in both workerd-backed development and production.

AWS Lambda

Install

pnpm remove @react-router/serve
pnpm add react-router-hono-server hono

Configure Vite

Create 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 the Lambda entry

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.

Add scripts

{
  "scripts": {
    "build": "react-router build",
    "dev": "react-router dev",
    "typecheck": "react-router typegen && tsc --noEmit"
  }
}

Build and deploy

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.

AWS runtime notes

  • 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.

Server customization

Create or reveal a Hono server entry before using these options:

npx react-router-hono-server reveal file

Hono app and middleware ordering

Pass 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 }));
  },
});

Chrome DevTools automatic workspaces

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.

Typed React Router context

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.

WebSockets

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/ws

The 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.

Basename and prerendering

Prerendering is configured by React Router in react-router.config.ts.

Prerender every static route

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.

Prerender selected routes

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.

Discover routes asynchronously

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 basename and Vite base

React Router's basename and Vite's base configure independent URL spaces:

  • basename mounts documents, route data requests, and the React Router handler.
  • base controls 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.

Deployment behavior

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.

API and exports

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

Vite plugin options

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.

Troubleshooting

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

About

React Router with Hono in less than 10 seconds

Topics

Resources

Code of conduct

Security policy

Stars

433 stars

Watchers

4 watching

Forks

Releases

Sponsor this project

Used by

Contributors

Languages