Adapter Architecture
This document describes how adapters work in @lazarv/react-server so that you can create new ones.
Overview
An adapter takes a built react-server application and packages it for a specific deployment target (cloud platform, runtime, etc.). The build pipeline is:
- Server build — RSC + SSR bundles →
.react-server/server/*.mjs - Client build — client components →
.react-server/client/*.mjs - Manifest — route/component manifests →
.react-server/*-manifest.json - Edge build (optional) — bundles the server into a single
edge.mjsfile (nonode_modulesneeded at runtime) - Static export (optional) — pre-renders HTML →
.react-server/dist/ - Adapter — copies/transforms the build output into the target's expected layout
Key Concepts
Two Runtime Modes
| Mode | Import | Returns | Static file serving | Use when |
|---|---|---|---|---|
| Node | @lazarv/react-server/node |
{ middlewares, handler } — a Node.js (req, res) middleware |
Built-in (via create-server.mjs) |
Target runs Node.js with node_modules |
| Edge | @lazarv/react-server/edge |
{ handler } — a fetch(Request) → Response handler |
Not included — you must serve static files yourself | Target uses Web Standard Fetch API or you want a single-file bundle |
Edge Build
When an adapter sets buildOptions.edge, the build produces .react-server/server/edge.mjs — a single ESM file that bundles all server code (react, react-dom, RSC runtime, route modules, config) into one file. This eliminates the need for node_modules at runtime.
The edge entry point is specified by the adapter and gets bundled via Vite/Rolldown with inlineDynamicImports: true. Inside the bundle, the adapter's entry calls reactServer() from @lazarv/react-server/edge and uses the returned handler to process requests.
Adapter Module Structure
Each adapter lives in adapters/<name>/ and exports:
adapters/<name>/
├── index.mjs # Main adapter module (required)
└── server/ # Runtime entry points (for edge builds)
└── entry.mjs # Bundled into edge.mjs
Required Exports from index.mjs
import { createAdapter } from "@lazarv/react-server/adapters/core";
// 1. buildOptions (optional) — influences the build before it runs
export const buildOptions = { ... };
// 2. adapter — created via createAdapter()
export const adapter = createAdapter({ ... });
// 3. default export — defineConfig function for user configuration
export default function defineConfig(adapterOptions) {
return async (_, root, options) => adapter(adapterOptions, root, options);
}
buildOptions
Can be an object or function (adapterOptions, cliOptions) => object. Queried by getAdapterBuildOptions() in lib/build/adapter.mjs before the build starts.
// Object form (most adapters)
export const buildOptions = {
edge: {
entry: join(adapterDir, "server/entry.mjs"), // your edge entry point
},
};
// Function form (e.g., Netlify — conditionally chooses edge vs node entry)
export const buildOptions = (adapterOptions, cliOptions) => ({
edge: {
entry: isEdge(adapterOptions, cliOptions)
? join(adapterDir, "functions/edge.mjs")
: join(adapterDir, "functions/node.mjs"),
},
});
The edge.entry file is the entry point that will be bundled into server/edge.mjs. It's your adapter's server runtime — the code that actually handles requests.
createAdapter()
createAdapter({
name: string, // Display name (e.g., "Bun", "Cloudflare Worker")
outDir: string, // Root output directory (e.g., ".bun", ".vercel/output")
outStaticDir?: string, // Where static files auto-copy (CSS, JS, assets, public)
outServerDir?: string, // Where server files auto-copy (MJS bundles, manifests)
handler: async ({ adapterOptions, files, copy, config, options, ... }) => R,
deploy?: { command, args } | (({ adapterOptions, options, handlerResult }) => { command, args }),
})
Returns an async function (adapterOptions, root, options) => void that:
- Clears
outDir - If
outStaticDiris set, auto-copies: static, assets, client, public files - If
outServerDiris set, auto-copies: server files to<outServerDir>/<reactServerOutDir>/ - Calls your
handlercallback - Handles deployment (runs or prints deploy command)
Handler Callback
The handler receives an object with:
| Property | Description |
|---|---|
adapterOptions |
User-provided config from defineConfig() |
files |
Lazy file getters (see below) |
copy |
File copy helpers (see below) |
config |
Resolved react-server config |
reactServerDir |
Absolute path to the react-server build output directory |
reactServerOutDir |
Relative outDir name (default ".react-server", configurable via outDir in react-server config) |
root |
Application root |
options |
Build CLI options (includes sourcemap, minify, deploy, etc.) |
files — Lazy File Getters
All return Promise<string[]> (relative paths from their source directory):
| Method | Source | Description |
|---|---|---|
files.static() |
dist/ |
Pre-rendered HTML (excludes .gz/.br; in edge mode also excludes PPR/RSC files) |
files.ppr() |
dist/ |
PPR files (.postponed.json, .prerender-cache.json, corresponding HTML) |
files.rsc() |
dist/ |
RSC payload files (rsc.x-component) |
files.compressed() |
dist/ |
Gzip/brotli compressed files |
files.assets() |
.react-server/ |
assets/**/* (Vite-built assets like CSS) |
files.client() |
.react-server/ |
client/**/* (excluding manifests) |
files.public() |
public/ |
User's public directory |
files.server() |
.react-server/ |
Server bundles, manifests, static modules, optionally sourcemaps |
files.dependencies(adapterFiles) |
resolved | Uses @vercel/nft to trace Node.js deps → { src, dest }[] |
files.all() |
— | Union of static + assets + client + public + server |
copy — File Copy Helpers
Each method copies the corresponding files.* set. Accepts optional out override:
| Method | Default dest | Description |
|---|---|---|
copy.static(out?) |
outStaticDir |
Pre-rendered HTML |
copy.ppr(out?) |
outServerDir |
PPR files (for server-side handling) |
copy.rsc(out?) |
outServerDir |
RSC payload files |
copy.compressed(out?) |
outStaticDir |
.gz/.br files |
copy.assets(out?) |
outStaticDir |
CSS and other Vite assets |
copy.client(out?) |
outStaticDir |
Client component JS bundles |
copy.public(out?) |
outStaticDir |
User's public directory files |
copy.server(out?) |
outServerDir |
Server MJS + manifests → <dest>/<reactServerOutDir>/ |
copy.dependencies(out, files?) |
— | Traces & copies all Node.js deps via @vercel/nft |
Note: If you set outStaticDir and outServerDir, the static/assets/client/public and server files are auto-copied before your handler runs. You only need to call copy.*() yourself if you need custom output destinations or if you didn't set those properties.
deploy
Either a static object or an async function. If --deploy CLI flag is passed, the command is executed; otherwise it's printed for manual use.
The deploy descriptor supports the following properties:
| Property | Type | Description |
|---|---|---|
command |
string |
The CLI command to run (e.g., "func", "swa", "wrangler") |
args |
string[] |
Arguments for the command |
cwd |
string? |
Working directory for the command (defaults to project root) |
message |
string? |
Help text shown when --deploy is not used |
afterDeploy |
() => void | Promise<void> |
Callback invoked after successful deployment (e.g., to print the deployment URL) |
// Static descriptor
deploy: {
command: "bun",
args: [".bun/start.mjs"],
}
// Async function returning a descriptor
deploy: async ({ adapterOptions, options, handlerResult }) => {
// Optionally provision resources, resolve app name, etc.
return {
command: "func",
args: ["azure", "functionapp", "publish", appName, "--javascript"],
cwd: outDir,
afterDeploy: () => {
banner(`deployed to https://${appName}.azurewebsites.net`, { emoji: "🌐" });
},
};
}
Core Utility Functions
Imported from @lazarv/react-server/adapters/core:
| Function | Description |
|---|---|
banner(msg, { emoji? }) |
Print a section header with spinner (interactive) or plain text (CI) |
message(primary, secondary?) |
Print a status line or update spinner |
success(msg) |
Print a success checkmark and stop spinner |
clearProgress() |
Stop any active spinner/interval |
writeJSON(path, data) |
Write pretty-printed JSON file |
readToml(path) |
Read and parse a TOML file (returns null on error) |
writeToml(path, data) |
Write a TOML file |
mergeTomlConfig(existingPath, adapterConfig) |
Read existing TOML and deep-merge with adapter config |
deepMerge(source, target) |
Deep merge objects (target/adapter takes precedence) |
clearDirectory(dir) |
rm -rf a directory |
getFiles(pattern, srcDir) |
Glob files |
getDependencies(files, dir) |
Trace Node.js dependencies with @vercel/nft |
spawnCommand(cmd, args, options?) |
Spawn a child process with optional { cwd } (for deploy commands) |
getConfig() |
Get resolved react-server config |
getPublicDir() |
Get absolute path to public directory |
Registration
After creating the adapter, register it in packages/react-server/package.json exports:
"./adapters/<name>": {
"types": "./adapters/adapter.d.ts",
"default": "./adapters/<name>/index.mjs"
}
All adapters share the adapter.d.ts type declaration which exports Adapter, BuildOptions, adapter, buildOptions, and defineConfig.
Writing an Edge Entry Point
The edge entry is the file specified in buildOptions.edge.entry. It gets bundled into a single edge.mjs by the build. Inside the bundle, all @lazarv/react-server/* imports are resolved and inlined.
Pattern
import { reactServer } from "@lazarv/react-server/edge";
import { createContext } from "@lazarv/react-server/http";
// 1. Initialize the server (once)
const { handler } = await reactServer({
origin: "...", // HTTP origin for URL resolution
outDir: ".", // Relative path to .react-server dir from where the bundle runs
});
// 2. For each request:
// a. Serve static files yourself (the edge runtime does NOT handle static files)
// b. Create an HTTP context
// c. Call the handler
// d. Handle cookies and errors
const httpContext = createContext(request, {
origin: "...",
runtime: "<name>", // Identifies the runtime (e.g., "bun", "cloudflare", "netlify")
platformExtras: { ... }, // Platform-specific objects (e.g., env, ctx for Cloudflare)
});
const response = await handler(httpContext);
// Handle set-cookie headers
if (httpContext._setCookies?.length) {
const headers = new Headers(response.headers);
for (const c of httpContext._setCookies) {
headers.append("set-cookie", c);
}
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}
return response;
createContext(request, options)
Creates the HTTP context object from a standard Request:
createContext(request, {
origin: string, // e.g., "https://example.com"
runtime: string, // e.g., "bun", "cloudflare", "netlify-edge"
platformExtras?: object, // Merged into context.platform (e.g., { env, ctx })
})
Returns an object with: request, url, method, headers, origin, platform, env, state, cookie, _setCookies, setCookie(), deleteCookie().
outDir in reactServer()
This tells the edge runtime where to find manifests and server modules relative to the working directory at runtime:
"."— when the bundlededge.mjsruns inside.react-server/(e.g., Cloudflare'sbase_diris set to.react-server)"../"— when it runs one level above- Default is
".react-server"if not specified
Static File Serving
The edge runtime does not serve static files. Your entry must handle this:
- Cloud platforms (Cloudflare, Netlify): The platform serves static files from a configured directory (e.g., Cloudflare's
env.ASSETS.fetch(), Netlify'sexcludedPath) - Standalone runtimes (Bun, Deno): You must implement static file serving in your entry (check file existence, serve with correct MIME types)
Existing Adapters Reference
Cloudflare (adapters/cloudflare/)
- Runtime: Edge (Cloudflare Workers)
- Entry:
worker/edge.mjs— usesenv.ASSETS.fetch()for static files - Output:
.cloudflare/static/+.cloudflare/worker/<reactServerOutDir>/ - Config: Generates
wrangler.toml, merges withreact-server.wrangler.toml - Static files: Handled by Cloudflare's
ASSETSbinding - Deploy:
wrangler deploy - Notes: Sets
base_dirto.cloudflare/worker/<reactServerOutDir>sooutDir: "."works
Netlify (adapters/netlify/)
- Runtime: Edge (Deno) or Node.js serverless
- Entry:
functions/edge.mjsorfunctions/node.mjs— chosen byedgeFunctionsoption - Output:
netlify/static/+netlify/edge-functions/ornetlify/functions/ - Config: Generates
netlify.toml, merges withreact-server.netlify.toml - Static files: Handled by Netlify CDN via
excludedPathconfig - Deploy:
netlify deploy --prod - Notes: Supports both edge and serverless via
buildOptionsfunction; serverless still uses edge build
Vercel (adapters/vercel/)
- Runtime: Node.js serverless (no edge build)
- Entry:
functions/index.mjs— uses@lazarv/react-server/node(Node middleware mode) - Output:
.vercel/output/static/+.vercel/output/functions/index.func/ - Config: Generates
.vercel/output/config.json - Static files: Handled by Vercel's filesystem routing (
{ handle: "filesystem" }) - Deploy:
vercel deploy --prebuilt - Notes: Only adapter that uses Node mode +
copy.dependencies()(not edge build). Does NOT setbuildOptions.edge.
Bun (adapters/bun/)
- Runtime: Edge (Bun.serve with fetch handler)
- Entry:
server/entry.mjs— exportshandler,createContext,port,hostname(minimal; no static file serving) - Output:
.bun/static/+.bun/server/<reactServerOutDir>/ - Config: Generates
start.mjswith build-time static route map +package.json - Static files: Build-time route map in generated
start.mjsusingBun.serve({ static })for zero-copy serving - Deploy:
bun .bun/start.mjs - Notes: Standalone runtime, no cloud config needed. Static routes are hardcoded at build time — no filesystem checks at runtime.
Deno (adapters/deno/)
- Runtime: Edge (Deno.serve with fetch handler)
- Entry:
server/entry.mjs— exportshandler,createContext,port,hostname(minimal; no static file serving) - Output:
.deno/static/+.deno/server/<reactServerOutDir>/ - Config: Generates
start.mjswith build-time static route map +deno.json - Static files: Build-time route map in generated
start.mjsusingDeno.readFile()for static serving - Deploy:
deno run --allow-net --allow-read --allow-env --allow-sys .deno/start.mjs - Notes: Standalone runtime, no cloud config needed. Static routes are hardcoded at build time. Uses
deno.jsonwithnodeModulesDir: "none"— nonode_modulesrequired.
Azure (adapters/azure/)
- Runtime: Edge (Azure Functions v4 programming model with streaming)
- Entry:
functions/entry.mjs— edge entry using@lazarv/react-server/edgewithcreateContext - Output:
.azure/static/+.azure/server/<reactServerOutDir>/+.azure/src/functions/server.mjs - Config: Generates
host.json,package.json(with@azure/functionsdependency),local.settings.json, andmain.bicep(IaC template) - Static files: Build-time route map in generated
src/functions/server.mjs— serves static files from disk viareadFileSync() - Deploy:
func azure functionapp publish <app-name> --javascript - Provisioning: When deploying with
--deploy, automatically provisions Azure resources (resource group, storage account, consumption plan, function app) using a Bicep template viaaz deployment group create. Skips provisioning if the function app already exists. - Adapter options:
appName— Function App name (falls back topackage.jsonname)resourceGroup— Azure resource group name (default:<appName>-rg)location— Azure region (default:"eastus")storageName— Storage account name (default: derived from appName, lowercase alphanumeric, max 24 chars)host— Extra properties to merge intohost.jsonenv— Extra environment variables forlocal.settings.json
- Notes: Uses edge build for single-file bundling. The v4 programming model's
app.http()supports returningReadableStreambodies, enabling response streaming. Static files are served by the function itself (no separate CDN). Requires Azure Functions Core Tools v4 (npm i -g azure-functions-core-tools@4) and Azure CLI (az) for auto-provisioning.
Azure SWA (adapters/azure-swa/)
- Runtime: Edge (bundled into single file, served via Azure SWA managed functions)
- Entry:
functions/entry.mjs— edge entry using@lazarv/react-server/edgewithcreateContext - Output:
.azure-swa/static/+.azure-swa/functions/server/ - Config: Generates
staticwebapp.config.json,host.json, andlocal.settings.json; merges withreact-server.azure.json - Static files: Handled by Azure Static Web Apps CDN via
navigationFallbackrouting - Deploy:
swa deploy .azure-swa/static --api-location .azure-swa/functions --api-language node --api-version 20 - Notes: Uses edge build. Targets Azure Static Web Apps with managed functions. Does not support response streaming (SWA buffers responses). Good for simpler/static-heavy apps.
Docker (adapters/docker/)
- Runtime: Node.js (default), Bun, or Deno — configurable via
runtimeoption - Entry: Node mode uses
server/index.mjswith@lazarv/react-server/node; Bun/Deno use edge build withserver/entry.edge.mjsvia@lazarv/react-server/edge - Output:
.docker/static/+.docker/server/+.docker/Dockerfile - Config: Generates
Dockerfile,.dockerignore, andpackage.jsonin.docker/ - Static files: Node: served by
server/index.mjs; Bun/Deno: served by generatedstart.mjswith inlined static routes - Deploy:
docker build -t <name>:latest .docker - Adapter options:
runtime—"node"(default),"bun", or"deno"name— Application/image name (falls back topackage.jsonname)tag— Docker image tag (default:<name>:latest)port— Container port (default:3000)version— Docker base image tag (default:"20-alpine"for Node.js,"alpine"for Bun/Deno)
- Notes: Node mode uses
copy.dependencies()for NFT tracing (like Vercel). Bun/Deno mode uses edge build with a single bundled file.buildOptionsis a function that conditionally enables edge mode based on theruntimeoption. Generates a single-stage Dockerfile — no build step inside Docker, resulting in smaller images and faster builds. Node images use tini for signal handling; all runtimes run as non-root user (except Deno). Usedocker run -p 3000:3000 <tag>to start the container.
Step-by-Step: Creating a New Adapter
Create the directory:
adapters/<name>/Decide: Edge or Node?
- Edge (recommended for most): Set
buildOptions.edge.entry→ single-file bundle, nonode_modules - Node: Don't set
buildOptions→ usecopy.dependencies()to trace and copynode_modules
- Edge (recommended for most): Set
Write the runtime entry (for edge adapters):
- Create
adapters/<name>/server/entry.mjs - Import
reactServerfrom@lazarv/react-server/edge - Import
createContextfrom@lazarv/react-server/http - Initialize server, handle requests, serve static files, manage cookies
- Create
Write
adapters/<name>/index.mjs:import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import * as sys from "@lazarv/react-server/lib/sys.mjs"; import { banner, createAdapter, message, success, writeJSON } from "@lazarv/react-server/adapters/core"; const cwd = sys.cwd(); const outDir = join(cwd, ".<name>"); const outStaticDir = join(outDir, "static"); const outServerDir = join(outDir, "server"); const adapterDir = dirname(fileURLToPath(import.meta.url)); export const buildOptions = { edge: { entry: join(adapterDir, "server/entry.mjs") }, }; export const adapter = createAdapter({ name: "<Name>", outDir, outStaticDir, outServerDir, handler: async function ({ adapterOptions, options }) { // Generate platform-specific config files banner("creating config", { emoji: "⚙️" }); // ... write config files, start scripts, etc. success("config created"); }, deploy: { command: "<cmd>", args: ["<args>"] }, }); export default function defineConfig(adapterOptions) { return async (_, root, options) => adapter(adapterOptions, root, options); }Register in
package.json:"./adapters/<name>": { "types": "./adapters/adapter.d.ts", "default": "./adapters/<name>/index.mjs" }Add output directory to
.gitignore: The adapter's output directory (e.g.,.<name>/) should be added to the project's.gitignore. This is handled automatically when users scaffold a project viacreate-react-server(theadapterIgnoremap insteps/deploy.mjs), but you should also add.<name>/to the root.gitignoreof the monorepo so that build artifacts from examples and tests are not committed.Test:
cd examples/hello-world pnpm build --adapter <name> # Inspect the output directory # Run the generated start command
User Configuration
Users configure adapters in react-server.config.mjs (or .json):
// react-server.config.mjs
export default {
adapter: ["<name>", { /* adapterOptions */ }],
// or just:
adapter: "<name>",
};
Or via CLI:
react-server build ./App.jsx --adapter <name>
react-server build ./App.jsx --adapter <name> --deploy
The adapter name resolves to @lazarv/react-server/adapters/<name> (built-in) or an npm package name (external).
Documentation Checklist
When adding a new built-in adapter, the following files need to be created or updated:
Package setup
packages/react-server/package.json— add an export entry:"./adapters/<name>": { "types": "./adapters/adapter.d.ts", "default": "./adapters/<name>/index.mjs" }
create-react-server scaffolding
packages/create-react-server/steps/deploy.mjs— add the new adapter as a choice in the deployment adapter prompt:- Add an entry to the
choicesarray in theselect()call (name, value, description) - Add the adapter's display name to the
adapterNamemap - Add the adapter's output directory / config files to the
adapterIgnoremap (used for.gitignoregeneration)
- Add an entry to the
English docs (docs/src/pages/en/)
- Create
(pages)/deploy/<name>.mdx— the adapter's dedicated docs page. Include frontmatter withtitleandcategory: Deploy. Cover installation, configuration options, how it works, build output, and deployment instructions. - Update
(pages)/deploy/adapters.mdx— add the new adapter to the list of available built-in adapters and update the config example if needed. - Update
deploy.(index).mdx— add a link to the new adapter page in the adapter listing paragraph.
Japanese docs (docs/src/pages/ja/)
Mirror all English changes:
- Create
(pages)/deploy/<name>.mdx— translated version of the English adapter page. - Update
(pages)/deploy/adapters.mdx— add the new adapter to the Japanese adapter listing. - Update
deploy.(index).mdx— add the new adapter link.
This file
packages/react-server/adapters/README.md— add a section for the new adapter under the existing adapter-specific headings, documenting its runtime mode, file layout, and any special behavior.