React Router development mode (HMR) with @setu-ts/react-router-plugin
In a scaffolded project
Create a full-stack project:
npm create @setu-ts my-app -- --template full-stack
cd my-app && npm install && npm run start:dev
Use npm run start:dev, bun run start:dev or deno task start:dev while editing. The application
serves pages and API routes on one application port. Vite runs in-process on its own port, and one
proxy route forwards client URLs under /__vite/. Route edits reach the next request without
restarting the application; the browser gets Hot Module Replacement and React Fast Refresh. No
production build runs in this loop.
For production, Node runs npm run build then npm start; Bun runs bun run build then
bun run start. Deno runs deno task start, which builds first. These entries use the compiled
frontend; Node also builds the service entry.
Cloudflare Workers projects have no dev.ts: npm run dev runs wrangler dev, which builds the
client and server first, without hot module replacement. npm run deploy ships the Worker.
Wiring it by hand (an existing application)
The plugin’s production path imports a compiled ServerBuild once, at register() time, so a
rebuild needs a process restart. This guide sets up a development loop where editing a route file
changes the rendered output with no restart, and the browser gets Vite’s Hot Module Replacement
and React Fast Refresh — while the setu-ts app still serves the SSR document and your API routes.
No plugin code is required. The loadRequestHandler option is the seam; everything here is
app-level configuration.
The mechanism below was verified end-to-end against a running kernel app on Deno 2.9.4, Vite
7.3.6 and react-router 8.3.0, and verified again on Deno 2.9.6, Vite 8.3.1 and
react-router 8.4.0, the versions resolved by setu new --template full-stack in that run. Where
a plausible-looking approach does not work, it is called out explicitly rather than omitted.
The scaffold’s dev.ts contains the wiring below plus viteDevExternals. That plugin keeps
framework packages and workspace libraries resolved by the server runtime, so a route module and the
entry share one module instance. Context keys from contextKeyFor() match by identity: the keys a
loader reads must be the same keys setu.config.ts sets.
Not related to
deno desktop --hmr. Deno 2.9.4 added--hmrsupport for React Router, but it is scoped to thedeno desktopcommand, which detects@react-router/devinpackage.jsonand shells out todeno task dev— React Router’s own Vite dev server, with the plugin and your kernel app out of the picture entirely. It is not a mechanism this plugin can use.
How it works
Two facts drive the design:
createRequestHandleraccepts a build thunk. Its signature is(build: ServerBuild | (() => ServerBuild | Promise<ServerBuild>), mode?: string). Passing a function makes the build re-resolve per request, so Vite’s module-graph invalidation is picked up without rebuilding the handler.- Vite must run in the same process.
ssrLoadModuleneeds theViteDevServerobject, so a separatereact-router devprocess cannot supply the server build. Vite runs in-process on its own port — not inmiddlewareMode— which also means Vite serves the client module graph and the HMR WebSocket itself.
The app then needs exactly one extra route: a proxy for the URLs Vite owns.
Setup
1. Pin the versions React Router requires
// package.json
{
"type": "module",
"dependencies": {
"react": "19.3.0", // MUST resolve to the exact same version as react-dom
"react-dom": "19.3.0",
"react-router": "^8.4.0",
"isbot": "^5", // only with React Router's default server entry; see note below
},
"devDependencies": {
"@react-router/dev": "^8.4.0",
"vite": "^8.0.0",
},
}
Two non-obvious requirements, both of which fail at ssrLoadModule/createServer time rather than
at install time:
reactandreact-dommust be the identical version, not merely compatible ranges.^19.0.0on both resolved to19.2.7and19.2.8and producedIncompatible React versions: The "react" and "react-dom" packages must have the exact same version.- Declare
isbotyourself if you use React Router’s default server entry. That entry imports it, and the dev plugin tries tonpm install isbot@5on the fly when it is missing — which fails inside Deno’s managednode_modules(npm error Cannot read properties of null). A project fromsetu new --template full-stackships its ownapp/entry.server.tsxand does not need it.
nodeModulesDir must be enabled, because Vite cannot be loaded from Deno’s global npm cache:
// deno.json
{ "nodeModulesDir": "auto" }
2. Namespace Vite’s URLs with base
// vite.config.ts
import { reactRouter } from '@react-router/dev/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [reactRouter()],
base: '/__vite/', // every client URL React Router emits gets this prefix
server: { port: 5199, strictPort: true },
});
base is what makes a single proxy route sufficient. Without it, the SSR document references
/app/root.tsx, /@react-router/critical.css, and /node_modules/... at the root — all of which
would hit the setu-ts app and collide with real application routes. With base: '/__vite/', those
become /__vite/app/root.tsx, /__vite/@react-router/critical.css, and so on.
server.origindoes not do this. It affects asset URLs produced by Vite’s asset pipeline, not the module script paths React Router emits into the document — those stayed root-relative in testing with and without it. Usebase.
The React Router Vite plugin also requires a real config file: passing configFile: false to
createServer fails with The React Router Vite plugin requires the use of a Vite config file.
3. Wire the dev seam
import * as vite from 'vite';
import { createRequestHandler, RouterContextProvider, type ServerBuild } from 'react-router';
import { createApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';
import { ReactRouterPlugin, viteDevExternals } from '@setu-ts/react-router-plugin';
const VITE_PORT = 5199;
const BASE = '/__vite/';
const viteServer = await vite.createServer({
root: import.meta.dirname,
configFile: `${import.meta.dirname}/vite.config.ts`,
server: { port: VITE_PORT, strictPort: true },
logLevel: 'error',
// Every @setu-ts package a route imports, resolved by Deno so the route and
// this entry share one module instance (context keys are matched by identity).
plugins: [
viteDevExternals({
packages: ['@setu-ts/common', '@setu-ts/react-router-plugin'],
resolve: (specifier) => import.meta.resolve(specifier),
}),
],
});
await viteServer.listen();
// Vite types a loaded module as Record<string, any>; this virtual module is
// React Router's ServerBuild by contract.
const loadServerBuild = async (): Promise<ServerBuild> =>
(await viteServer.ssrLoadModule('virtual:react-router/server-build')) as ServerBuild;
const app = createApplication();
app.register(RuntimePlugin());
app.register(
ReactRouterPlugin({
// Unused when loadRequestHandler is supplied, but the option is required —
// pass the virtual id so the health indicator reports something meaningful.
serverBuildPath: 'virtual:react-router/server-build',
mode: 'development',
// Omit assetsDir in dev: there is no build/client, and Vite serves the client graph.
loadRequestHandler: (_path, mode) => {
const handler = createRequestHandler(loadServerBuild, mode);
return Promise.resolve({
// The plugin supplies the instance returned by createLoadContext below.
// Its public handler type uses unknown so it can stay React Router-free.
handler: (request, loadContext) => handler(request, loadContext as RouterContextProvider),
// Must come from the same react-router module as the handler — RR checks
// `context instanceof RouterContextProvider` nominally.
createLoadContext: () => new RouterContextProvider(),
});
},
}),
);
Note the two halves of SsrRuntime. createLoadContext is not optional busywork: React Router 8
rejects any context that is not a real RouterContextProvider instance with a 500, so a dev seam
that returns only a handler cannot work. See the Notes section of PUBLIC_API.md for the full
rationale.
4. Add the single proxy route
app.router.get(`${BASE}*`, async (ctx) => {
const url = new URL(ctx.request.url);
const upstream = await fetch(`http://localhost:${VITE_PORT}${url.pathname}${url.search}`, {
headers: ctx.request.headers,
});
ctx.response.status(upstream.status);
for (const [key, value] of upstream.headers.entries()) {
const lower = key.toLowerCase();
if (lower !== 'content-encoding' && lower !== 'content-length') {
ctx.response.appendHeader(key, value);
}
}
return ctx.response.stream(upstream.body!);
});
await app.start({ port: 5299 });
/__vite/* carries one static segment while the plugin’s /* catch-all carries none, so it
outranks it under the precedence rules in PUBLIC_API.md — registering it after ReactRouterPlugin
works, which is how the verification above was run.
Registration order does not enter into it. Before M70g a * counted as a static segment, so a
single-segment route tied with the catch-all and whichever registered first won; the ranking is now
static segments descending, then wildcards ascending, then registration index, and a route naming
its own path always outranks a wildcard. A second bare /* is not shadowed either — the kernel
refuses a duplicate METHOD path, naming the plugin that registered it first.
The HMR WebSocket needs nothing from you
Vite’s HMR client connects back to Vite’s own port, not the app’s. That is deliberate and worth being explicit about:
- No
IHttpAdapter.setUpgradeRouterinvolvement, so no conflict withWebSocketPlugin— the adapter exposes a single upgrade-router slot, and the WebSocket plugin claims it. - If the browser cannot reach Vite’s port directly (containers, remote dev), set
server.hmr.port/server.hmr.clientPortin the Vite config rather than trying to route the socket through the app.
Production
Nothing changes: build with react-router build, then register the plugin with the compiled build
and let it use the default loader.
app.register(
ReactRouterPlugin({
serverBuildPath: new URL('./build/server/index.js', import.meta.url).href,
assetsDir: './build/client/assets',
mode: 'production',
}),
);
Keep the dev-only Vite import and proxy route behind an environment check so neither reaches a production bundle.
The example above registers ReactRouterPlugin on a bare application. A real application’s loaders
usually read capabilities its composition provides, such as a session, which a bare application does
not register, so those pages answer 500 in this loop. Pass the same loadRequestHandler and
mode: 'development' through the composition you already have instead. The full-stack starter
forwards its reactRouter option to the plugin unchanged. Vite stays an app-level devDependency
and is never imported by the plugin (AI_GUIDELINES §12.2).
Notes on entry.server.tsx under Deno
Deno’s own framework documentation states that React Router’s default server entry targets Node and
that a Deno-compatible app/entry.server.tsx is needed before building for deno desktop. That
did not reproduce here. With no custom entry at all, React Router 8.3.0’s default server entry
rendered a document successfully under Deno 2.9.4, both through createRequestHandler directly and
through the plugin over a real socket.
Treat a custom entry as something to reach for when you hit an actual Node-only API — not as a prerequisite. If you do write one, it is app-level and invisible to the plugin, which consumes only the compiled build.
Verified behavior summary
| Step | Result |
|---|---|
import 'vite' under Deno | works (requires nodeModulesDir) |
A route importing @setu-ts/* at run time, on Deno in the recorded verification | 500 unless viteDevExternals is used |
The same route with viteDevExternals, on Deno | 200, context keys shared |
@react-router/dev/vite under Deno | works |
vite.createServer() in-process | works (real config file required) |
ssrLoadModule('virtual:react-router/server-build') | returns the ServerBuild |
| SSR through the build thunk on the app port | 200 HTML |
Client modules under base, proxied through the app port | 200 text/javascript |
| Vite HMR client reachable through the app port | 200 |
| Editing a route file, then re-requesting | new output, no restart |
server.origin rewriting emitted module URLs | does not work — use base |
configFile: false | rejected by the React Router plugin |
Mismatched react / react-dom patch versions | throws at ssrLoadModule |