Appearance
WHATWG Request/Response Adapter
Use standard WHATWG Request and Response objects with Express instead of Node's http.IncomingMessage and http.ServerResponse. Write framework-agnostic handlers that work across Express, Cloudflare Workers, Deno, and any platform supporting the Fetch API.
When to Use
- Writing handlers that should be portable across server runtimes (Node.js, Deno, Cloudflare Workers, Bun)
- Preferring the Fetch API's
Request/Responseover Express'sreq/res - Migrating from Express to a standards-based server incrementally
- Using libraries that expect WHATWG
Requestobjects
The Pattern
The @whatwg-node/server package bridges Express and the WHATWG Fetch API. It translates Express's req/res into standard Request/Response objects and back.
Adapt for Express
Use createServerAdapter to mount a WHATWG handler in Express:
javascript
import express from "express";
import { createServerAdapter }
from "@whatwg-node/server";
const app = express();
const adapter = createServerAdapter(handleRequest);
app.use("/api", adapter);
app.listen(3000);Adapt for other runtimes
The same handleRequest function works directly in platforms that natively support the Fetch API:
javascript
// Cloudflare Workers
export default {
fetch: handleRequest,
};javascript
// Deno
Deno.serve(handleRequest);javascript
// Bun
export default {
fetch: handleRequest,
};Writing framework-agnostic handlers
Write handlers using the standard Fetch API signature — a function that receives a Request and returns a Response:
javascript
async function handleRequest(request) {
const url = new URL(request.url);
if (url.pathname === "/api/hello") {
return new Response(
JSON.stringify({ message: "Hello" }),
{
status: 200,
headers: {
"Content-Type": "application/json",
},
},
);
}
return new Response("Not Found", { status: 404 });
}This handler has no Express dependency — it receives a standard Request and returns a standard Response.
Key Concepts
The Fetch API is the universal interface. The WHATWG Request and Response classes are available in all modern JavaScript runtimes. Writing handlers against this interface makes them portable.
Adapters bridge the gap. Express uses Node's IncomingMessage/ ServerResponse. The adapter translates bidirectionally so existing Express middleware still works alongside WHATWG handlers.
Incremental migration. Mount WHATWG handlers on specific paths while keeping the rest of the Express app unchanged. Migrate route by route.
JSON API endpoint
Use Response.json() for cleaner JSON responses, and branch on request.method for REST semantics:
javascript
async function handleUsers(request) {
if (request.method === "GET") {
const users = await db.listUsers();
return Response.json(users);
}
if (request.method === "POST") {
const body = await request.json();
const user = await db.createUser(body);
return Response.json(user, { status: 201 });
}
return new Response("Method Not Allowed", {
status: 405,
});
}Reading request body
The Request object provides typed body parsing methods like formData(), json(), and text():
javascript
async function handleUpload(request) {
const formData = await request.formData();
const file = formData.get("file");
// process file...
return new Response("OK");
}Streaming response
Return a ReadableStream body to stream data without buffering the full response in memory:
javascript
function handleStream() {
const stream = new ReadableStream({
start(controller) {
controller.enqueue("chunk 1\n");
controller.enqueue("chunk 2\n");
controller.close();
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/plain",
},
});
}Combining with File-Based Routing
The file-based routing pattern and the WHATWG adapter complement each other: file structure defines what routes exist; Request/Response defines how handlers are written.
Rather than branching on request.method inside a single handler, export a named function per HTTP verb — get, post, put, etc. The router registers only the methods each file exports; all others return 404 automatically with no explicit handling needed.
javascript
// api/accounts/[id]/index.js
export async function get(request, { params }) {
const account = await db.getAccount(params.id);
return Response.json(account);
}
export async function put(request, { params }) {
const body = await request.json();
const updated = await db.updateAccount(params.id, body);
return Response.json(updated);
}
// DELETE, POST, PATCH — not exported → automatic 404The router scans each module for recognized HTTP method names and mounts only the handlers it finds:
javascript
// autoRouter.js — WHATWG-aware version
import { join } from "node:path";
import { globSync } from "glob";
import express from "express";
import { createServerAdapter } from "@whatwg-node/server";
const HTTP_METHODS = ["get", "post", "put", "patch", "delete", "head", "options"];
const defaults = { pattern: "**/*.js" };
export async function autoRouter(dir = process.cwd(), options) {
options = { ...defaults, ...options };
const filePaths = globSync(join(dir, options.pattern));
const app = express();
for (const filePath of filePaths) {
const browserPath = toBrowserPath(filePath, dir);
const module = await import(filePath);
for (const method of HTTP_METHODS) {
if (typeof module[method] === "function") {
const handler = module[method];
app[method](browserPath, (req, res, next) => {
createServerAdapter(
(request) => handler(request, { params: req.params }),
)(req, res, next);
});
}
}
}
return app;
}Cloudflare Pages Functions uses the same idea with capitalized onRequestGet, onRequestPut exports — the mental model carries over even though the exact API differs:
javascript
// Cloudflare Pages Functions: functions/accounts/[id].js
export async function onRequestGet({ request, params }) {
const account = await db.getAccount(params.id);
return Response.json(account);
}
export async function onRequestPut({ request, params }) {
const body = await request.json();
const updated = await db.updateAccount(params.id, body);
return Response.json(updated);
}Trade-offs
| Approach | Pros | Cons |
|---|---|---|
| WHATWG adapter | Portable, standard | Extra dependency |
Express req/res | Familiar ecosystem | Locked to Express |
| Direct Node HTTP | No framework | Low-level, verbose |
Adopt when portability matters. If the server will only ever run on Express, the adapter adds indirection without clear benefit. If handlers need to run across runtimes, the standard Fetch API is the natural choice.