Edge / Single-Isolate Rendering
The default build renders HTML through worker threads under a --conditions
react-server process flag. That model is great on a full Node server, but it
does not fit single-process targets — serverless functions and edge-style
runtimes (Bun, Deno, Vercel/Netlify Functions) — which have no worker_threads
and no process-level export conditions.
The single-isolate edge build removes both requirements. It bakes a second,
self-contained bundle in which React is inlined under the react-server
condition at build time, so the running isolate needs no worker_threads and no
runtime --conditions. You get flash-free streaming SSR from one Web fetch
handler.
One naming precision: "edge" here refers to the single-isolate shape, not to a
literal V8-isolate runtime. Which runtimes the handler reaches is a property of
the transport the render path uses, picked at build time
(build.edge.transport):
"esm"(the default) resolves client islands withimport(moduleBaseURL + id)off disk and react-dom through the Node module resolver at request time. Its homes are Node-compatible hosts: Node servers, Bun, Vercel/Netlify Node functions."webpack"resolves references through a closed module map instead, and the bake additionally emits a baked consumer (dist/server-edge/consumer.js): client React plus every client-reference module behind a closed registry. That pair composes to a bundle with nonode:imports and noprocessdependency — what a filesystem-less runtime (Cloudflare Workers, Deno Deploy) requires. Serve it throughvite-plugin-react-server/edge/web— see Workers / Deno below.
Under runner "main" or "isolated" the bake is additive: the normal
dist/server / dist/static output is untouched, dev is unaffected, and a
bake failure warns without failing the build. Declaring runner: "edge"
promotes the pair to the paradigm: the build requires transport: "webpack",
build.edge: false is a config-time contradiction, the SSG snapshots render
through the pair, and a bake failure fails the build — an edge-runner build
without its serving artifact is a broken deploy. The edge runner is a
production serving choice: its dev server runs the isolated worker shape
(same app code, worker-owned react-server resolution), and the baked pair is
what deploys.
How it works
Two bundles co-exist in one isolate:
dist/server-edge/render.js— the Flight producer. React is baked under thereact-servercondition (server components), so this is where your Page renders to an RSC Flight stream. It exports one function:export function renderRouteToFlight(url: string, request?: Request): Promise<ReadableStream<Uint8Array>>;It is a closed manifest: every module is baked in by static import, so there is no runtime
import(). The enumeratedbuild.pagesset is baked route-by-route; with the file router, each dynamic route pattern's modules are baked once as well, so an unenumerated concrete url (any/profile/<id>that was not instaticPaths) still renders per-request by matching the route patterns. A url matching nothing throws — whichcreateEdgeHandlerturns into a 404. Pass the in-flightrequestso a loader can gate on cookies/headers (authenticated routes); it isundefinedat prerender.dist/client— the client (ssr) bundle. React is the normal client build. The HTML render and client islands live here.
renderFlightToHtml decodes the Flight stream and renders HTML in the same
process under client React. Client islands resolve through the client
transport's native import(moduleBaseURL + id) into dist/client — no
condition-sensitive resolution. The two React copies never collide because each
was baked into its own graph.
Request ──▶ renderRouteToFlight(url) ──▶ Flight stream (dist/server-edge, server React)
│
▼
renderFlightToHtml(...) ──▶ HTML stream (dist/client, client React)
│
▼
Response
Enable it
// vite.config.ts
vitePluginReactServer({
runner: "isolated",
moduleBase: "src",
Page: "src/page.tsx",
props: "src/props.ts",
build: {
pages: ["/"],
// edge is ON by default. Omit it entirely to get the defaults, or:
// edge: false — opt out (no dist/server-edge artifact)
// edge: { minify: false } — keep on, tune a default
},
});
build.edge is boolean | { outDir?, minify? }, default true. Under
runner "main" or "isolated" the bundle is additive — the worker-based
dist/server build is untouched, and a bake failure is a warning, never a
build failure. Under runner "edge" the same knob describes the serving
artifact itself: a bake failure fails the build, and edge: false is a
config-time contradiction (see above).
| form | meaning |
|---|---|
edge: true / omitted | emit the baked edge bundle with defaults |
edge: false | opt out — no dist/server-edge artifact |
edge: { … } | emit, with overrides (presence means enabled) |
| option | default | meaning |
|---|---|---|
outDir | "server-edge" | output dir, under build.dir |
minify | true | minify the bundle. It bakes React in, so it is large; edge platforms cap bundle size. Set false for readable output. |
transport | "esm" (or the top-level transport when set) | which RSC transport the baked render path uses. "webpack" additionally emits the baked consumer (consumer.js) for filesystem-less runtimes. Transports don't mix within one deploy: prefer setting the top-level transport, which renders the page snapshots through the baked pair too, so snapshots and per-request routes carry one flavor. Overriding only this field leaves the snapshots esm-encoded; that split serves every route through the edge bundle and drops the page snapshots from what it hosts. |
Serve it: createEdgeRequestHandler
Hand the baked bundle to createEdgeRequestHandler and you have the server:
import * as bundle from "./dist/server-edge/render.js";
import { createEdgeRequestHandler } from "vite-plugin-react-server/edge";
export const handler = createEdgeRequestHandler(bundle);
export default { fetch: handler }; // Node server, Bun, a Node serverless function
That is the whole thing: a Web (Request) => Response serving the flash-free
document on a GET, the headless flight on a client navigation, and "use server"
actions through the bundle's baked gate.
Import the bundle as a namespace and pass it whole. It carries its own routes,
styles, client entry and action gate, so there is nothing to wire up — and a
static import stays visible to a deploy's file tracer. Handing over a path for
the handler to import() at runtime is what makes the module go missing on the
platforms this handler exists to serve.
vite-plugin-react-server/edge is condition-neutral: it resolves to the same
module with or without --conditions react-server, so it is safe to import from
code that a react-server-condition build also loads.
This entry injects vprs's own flight → HTML renderer as the default — which
resolves react-dom through the Node module resolver, and is exactly what keeps
createEdgeRequestHandler(bundle) zero-config on Node. Bundling for a Worker,
use /edge/web instead (next section): same factories, no Node renderer in the
module graph.
Options
| option | default | meaning |
|---|---|---|
dynamic | every baked route | which routes render per request — a predicate, or a list of urls and/or route patterns (/blog/$slug). A serving-layer choice, so one build can be served static, dynamic, or a mix. |
cssFiles | baked globalCss only | per-route stylesheets for the document render and the navigation flight: a Map<string, CssContent>, or a function of the route url returning one (what the host manifest's cssByPattern names for the matched route). The build's webpack freeze passes each route's own css here. |
projectRoot | — | forwarded to the baked action gate |
actionHeader | "x-rsc-action" | header marking a POST as a server action |
rscOutputPath | "index.rsc" | filename the client router fetches a flight from |
It also accepts createEdgeHandler's options below (headers, nonce,
onError, onNotFound, …). moduleBaseURL and bootstrapModules come from the
bundle's own bake; set them only to override.
Serving files from the same process
createEdgeRequestHandler touches no filesystem: assets and prerendered
snapshots are the host's to serve, and a CDN does it better. To serve them from
disk anyway — a local runner, a Node self-host — compose createEdgeRenderHook
into createRequestHandler:
import { createServer } from "node:http";
import { createRequestHandler, toNodeListener } from "vite-plugin-react-server/request-handler";
import { createEdgeRenderHook } from "vite-plugin-react-server/edge";
import * as bundle from "./dist/server-edge/render.js";
const app = createRequestHandler({
staticDir: "dist/static",
render: createEdgeRenderHook(bundle), // null for a route it doesn't render
action: bundle.handleRouteAction,
});
createServer(toNodeListener(app)).listen(8787);
Workers / Deno: the baked consumer and /edge/web
With build.edge.transport: "webpack", the bake emits two bundles:
dist/server-edge/render.js— the producer, as above, rendering through the webpack transport's closed client manifest.dist/server-edge/consumer.js— the flight → HTML half: client React,react-dom/server.edge, the webpack flight client, and every client-reference module compiled in behind a closed registry. It exportsrenderFlightToHtmlwith the runtime renderer's contract minus the options that describe how to find things (moduleBaseURL,clientManifest,flightTransport) — the bundle already contains them.
Compose them through the /edge/web entry:
import * as bundle from "./dist/server-edge/render.js";
import { renderFlightToHtml } from "./dist/server-edge/consumer.js";
import { createEdgeRequestHandler } from "vite-plugin-react-server/edge/web";
export default { fetch: createEdgeRequestHandler(bundle, { renderFlightToHtml }) };
That whole request path — producer, consumer, handler — bundles for a Worker
with zero node: import specifiers and zero require, and runs with no
process global at all.
/edge/web exists because the difference from /edge is the module graph,
not behavior: /edge can reach vprs's built-in Node renderer, and even an
unreached dynamic import is a specifier every bundler resolves — so a Worker
build from that entry drags node:module in. /edge/web cannot reach it,
which is why renderFlightToHtml is required there (checked at creation,
so a handler that cannot render fails at compose time, not first request).
Like /edge, it is condition-neutral.
Two constraints to know:
- Transports don't mix. A webpack-transport client cannot decode
esm-encoded
.rscsnapshots or inline-flight payloads. With the top-leveltransport: "webpack"the snapshots render webpack-encoded too, so this never splits. With onlybuild.edge.transport: "webpack"set, the snapshots stay esm-encoded and the deploy serves every route through the edge bundle rather than hosting the page snapshots (assets and chunks are still the CDN's to serve). - The consumer bakes client React in, so it is a second large artifact — the same size trade as the producer, for the same reason.
createEdgeRequestHandler is built on createEdgeHandler, which composes the
producer and the HTML render into a standard Web (Request) => Response handler.
Reach for it directly only to drive the producer yourself:
import { renderRouteToFlight } from "./dist/server-edge/render.js";
import { createEdgeHandler } from "vite-plugin-react-server/stream";
const handler = createEdgeHandler({
render: renderRouteToFlight,
moduleBaseURL: "/", // where dist/client is served
bootstrapModules: ["/client-abc123.js"], // your client entry (for hydration)
});
export default { fetch: handler }; // Node server, Bun, a Node serverless function
The handler streams (responds when the HTML shell is ready), returns 404
for a url that matches no baked route or route pattern (override via
onNotFound), and propagates other render errors after onError.
Options
| option | default | meaning |
|---|---|---|
render | — | the baked renderRouteToFlight |
moduleBaseURL | "/" | base url where dist/client is served; client islands resolve against it. Mind a non-root deploy base. |
bootstrapModules | [] | client entry module(s) to bootstrap hydration |
bootstrapScriptContent | — | inline bootstrap script |
nonce | — | CSP nonce |
getURL | req => new URL(req.url).pathname | map a Request to a baked route url |
headers | — | merged over the default content-type: text/html |
onError | — | render-error hook |
onNotFound | 404 text/plain | response for an unbaked route |
Finding bootstrapModules
You don't. The bake reads the client entry's hashed filename out of the build
manifest and exports it from the bundle as bootstrapModules, alongside
clientModuleBaseURL (the built client bundle, resolved from import.meta.url).
createEdgeRequestHandler uses both, which is why it needs no wiring.
This is baked rather than read at runtime because a server that reads
.vite/manifest.json on each boot forces the deploy to ship that dot-directory
to the function — and platforms decline to in ways that only surface in
production (Vercel's includeFiles glob skips dot-dirs, and its tracer will not
follow a JSON import). Baking it into the module the server already imports
leaves nothing extra to ship.
Only when driving createEdgeHandler yourself do you pass them by hand:
import * as bundle from "./dist/server-edge/render.js";
createEdgeHandler({
render: bundle.renderRouteToFlight,
moduleBaseURL: bundle.clientModuleBaseURL,
bootstrapModules: bundle.bootstrapModules,
});
A non-root deploy base (e.g. GitHub Pages) is the usual reason hydration breaks. The baked values already honor
base; if you override them, keepmoduleBaseURLand thebootstrapModulesprefix in sync with where you actually host the client build — and verify in a real prod build at the real base.
Platform bindings
Whatever the runtime passes to your fetch handler after the request (workerd's
env and ctx, or nothing on Node) travels through the handler as
platform and surfaces in two places:
- Loaders:
props(url, ctx)receives it asctx.platform— an array of the extra arguments, soctx.platform[0]is workerd'senv. - Server actions: every action is called with a trailing context argument,
action(...args, { platform, refresh }). Existing actions ignore it; an action that declares the extra parameter reads its bindings from it (refresh()is described in server-actions.md). The shape is identical in dev and on Node —platformis just empty there — so action code behaves the same everywhere.
There is no global to reach for: bindings arrive per request, which is also the only form workerd supports.
Run it on Node
On a real edge platform you export the handler directly. On Node, wrap it in a
tiny adapter that serves dist/client and routes the rest through the handler.
The examples/hello-world example ships a complete one — see
edge-server.mjs:
cd examples/hello-world
npm run build # emits dist/server-edge/render.js + dist/client
npm run edge # node edge-server.mjs → http://localhost:8787
Flash-free documents (renderRouteToDocument)
renderRouteToFlight renders partial markup. For a full flash-free document —
one whose initial HTML carries the live data and hydrates with no .rsc refetch —
the bundle also exports renderRouteToDocument:
renderRouteToDocument(url, { cssFiles, globalCss }): Promise<{
full: ReadableStream; // Html/Root-wrapped document flight
headless: ReadableStream; // Root-only #root contents, for the inline payload
}>
createEdgeHandler's document mode drives it: it renders full to a complete
HTML document and delivers headless inline, so the browser hydrates in place.
Its inlineFlight option picks the delivery: "blob" (default) buffers the
document and splices the whole flight in as one <script id="vprs-flight">;
"stream" responds with the HTML as it renders, interleaving the flight as
executable push-script chunks between React's flushes — no full-document
buffering, TTFB is the shell flush. Under a script-src CSP pass nonce: the
interleaved scripts are executable and are stamped with it.
const handler = createEdgeHandler({
renderDocument: (url) => renderRouteToDocument(url, { cssFiles }),
moduleBaseURL: pathToFileURL(join(buildDir, "client")).href + "/", // ssr bundle, on disk
bootstrapModules: ["/" + clientEntry],
});
The in-process render decodes the flight under client React and resolves client-component references by importing them from the ssr bundle (
dist/client) — somoduleBaseURLhere is that directory as a file URL, not the browser's HTTP base. The browser hydrates from its own base separately; the client-component filenames are hash-identical acrossdist/clientanddist/static, so the same refs resolve on both sides.
Pass live cssFiles/globalCss (a Map<string, CssContent>, e.g. via
collectManifestCss) so the document and the inline payload carry the same styles.
createEdgeRequestHandler takes the same cssFiles — a map, or a function of
the route url returning one — and forwards it to the baked producer on both
branches, the document and the navigation flight (the route's css rides in the
Root, so a flight without it leaves the route unstyled after a client
navigation); the build's webpack freeze passes each route's own stylesheets
(the closure of its page module, props loader and layout chain, what the host
manifest records as cssByPattern) through exactly that option, so a frozen
document is styled like the esm pass's.
Server actions, no --conditions (handleRouteAction)
A "use server" app needs the server RSC transport to decode/dispatch actions —
which normally forces --conditions react-server and so conflicts with the
client-React document render above. The bundle resolves this by baking the
action gate too: it exports handleRouteAction, a sealed gate over the action
modules (the *.server.* allowlist) baked in with server React, so the action
path never disk-imports the transport.
import { createRequestHandler } from "vite-plugin-react-server/request-handler";
const { renderRouteToDocument, handleRouteAction } = await import(edgeBundleUrl);
const handler = createRequestHandler({
staticDir,
action: (request) => handleRouteAction(request, { projectRoot }), // baked gate (a function)
render: async (pathname, request) => { /* renderDocument for dynamic routes */ },
});
createRequestHandler lazy-imports its built-in (disk) gate, so passing the baked
function keeps the whole server condition-neutral. The baked gate is still a
sealed allowlist — an id the build did not enumerate is rejected. This is the
shape that runs a full server-actions app (live data + flash-free SSR) in one
isolate with NODE_OPTIONS unset; see the bidoof-template demo's start.tsx.
⚠️ Do not statically import or re-export your built
*.server.*modules in the no---conditionsprocess. A built"use server"module imports the react-server transport at load (registerServerReference), which asserts thereact-servercondition and crashes the server at startup. Let the baked gate own them — dispatch throughhandleRouteActionand keep the entry a side-effect import. The actions are still built (reachable via your pages/props), so the gate's allowlist is unchanged.
// ❌ server/index.ts — crashes at startup with no --conditions:
// "The react-server condition must be enabled ..."
export { addTodo } from "./actions.server.js"; // ← pulls the react-server transport
import "./start.js";
// ✅ server/index.ts — side-effect import only; nothing here pulls the transport
import "./start.js";
// addTodo & co. are dispatched at request time through the baked gate
// (handleRouteAction), which carries its own server React.
When to use it
- Use it for any single-process deploy where you want flash-free streaming
SSR without
worker_threadsor a--conditionsflag — the default transport on Node-compatible hosts, the webpack transport (+ baked consumer) on Cloudflare Workers / Deno Deploy. - Stick with the default worker-based build for Node servers that already
run under
--conditions react-server, or for purely static (SSG) hosting — the edge bundle is an extra artifact you do not need there.
Limitations
- The producer is a closed manifest of baked modules. With the file router,
unenumerated urls that match a route pattern render per-request; urls that
match no pattern (and are not in
build.pages) 404. There is no runtimeimport()of new modules. - The bundle inlines React, so it is large — keep
minify: truefor deploys and watch your platform's size limit. createEdgeHandler/renderFlightToHtmlare client-condition exports (they run client React); import them fromvite-plugin-react-server/streamwithout thereact-servercondition. Only the bakedrender.jsis server React.vite-plugin-react-server/edgeand/edge/webhave no such caveat — each resolves to one module under either condition and defers any client-React import until a render, so importing them from a react-server build is safe.- The baked action gate (
handleRouteAction) enumerates*.server.*modules as the allowlist — a"use server"action must live in such a module (the common convention). Inline"use server"in a non-.server.file is not baked.