00 An explorable comparison · Node · Express · Next
Two bloodlines
of req & res.
The same job — read a request, send a response — looks different in raw Node, in Express, and in Next.js. The reason is ancestry, and there are only two lines: one descends from Node streams, the other from the Web platform's Fetch standard. The twist is that Next belongs to both — its App Router crosses to the Web line while its Pages API stays Node-blooded, a sibling of Express. Everything else follows from where an object was born. Click the diagram to inspect it.
Two independent axes. Bloodline answers layer 2 — and because Node streams can't exist without a Node process, it constrains layer 1 too.
01 The one split
Express decorates Node. Next extends the Web.
There are only two ways to give a request object more power, and each picked one. Express takes Node's IncomingMessage and glues new keys onto the very same instance — same object, more properties. Next's App Router goes the other way: NextRequest is a subclass of the Web platform's Request — a different base entirely. Next still runs on Node by default; that's the runtime, not the request object, and the two come apart in the next section. The split itself is one character of TypeScript.
type ExpressReq = IncomingMessage & { … } vs class NextRequest extends Request { … }
// decoration — additive, same instance, base untouched ExpressReq = IncomingMessage & { query, params, body } → still an IncomingMessage // extension — a new class on a different base NextRequest extends Request → NOT an IncomingMessage
Because decoration only ever adds, every Node property survives unchanged. Because extension swaps the foundation, NextRequest can run on the Edge, where there is no Node IncomingMessage to decorate in the first place. But "Next" isn't one thing: its older Pages API routes decorate Node exactly like Express — so the real fault line runs between routers, not frameworks.
02 Two layers · and Next in both
Runtime is not request shape.
"But I thought Next used Node?" It does. The confusion comes from collapsing two questions that are actually independent. The runtime layer asks who executes your code — a full Node.js process, or a bare V8 isolate at the Edge. The request-API layer asks what object your handler is handed — Node's IncomingMessage, or the Web platform's Request. Express answers both with Node. Next's App Router answers the first with "Node or Edge" and the second with "Web."
And here is the twist the word bloodlines was hiding: Next's Pages API answers both with Node. In pages/api, req is an IncomingMessage and res a ServerResponse, decorated with res.json() and res.status() — the same decoration trick Express plays. So Next.js is not one bloodline. It has a child in each: the App Router on the Web line, the Pages API on the Node line, sitting right beside Express.
Express: here is Node's req/res, made nicer. Next App Router: here is a Web Request, return a Web Response — the runtime is ours to handle. Next Pages API: here is Node-style req/res, made Next-friendly.
Probe it three ways — flip the matrix to feel the two layers move independently, classify any object by its bloodline, or read the same task in every dialect at once.
03 The cast & their lineage
Ten objects, two bloodlines.
Knowing which base each object descends from explains every behavioural quirk that follows — what url contains, whether headers are a plain object or a Headers instance, whether you mutate or return. Grouped by bloodline the families separate cleanly — and Next turns up in each.
| Object | Role | Lineage | Relationship to its base |
|---|
04 Request · the same job, four dialects
Reading what arrives.
Pick a task. The shorter the code gets, the more plumbing the layer is hiding for you — a raw string becomes an auto-parsed object becomes a typed helper. The Next column has two faces: flip it between the App Router (Web) and the Pages API (Node), or open the full four-column view.
05 Response · the same job, four dialects
Sending what you reply with.
The mirror image. Next's App Router verbs are mostly static factories that return a value; Express's are instance methods that mutate one — and the Pages API, true to its Node blood, mutates right alongside Express.
06 The behavioural consequence
You poke a stream. You return a value.
This is what the type difference causes — and notice the split tracks bloodline, not framework. A decorated ServerResponse is a writable stream, so you mutate it step by step; Express and the Node-blooded Next Pages API both live on this side. A NextResponse is-a Web Response — an immutable value — so the App Router constructs it whole and hands it back. Step through both.
Allocate the response. One side will be poked five times; the other won't exist until it's whole.
headers and cookies are mutable views you may set before returning. But the body and status are fixed at construction, and you never re-open a stream to write() again.
07 Provenance — everything on one card
Where each capability comes from.
Next splits into two columns here, and the resemblance is the whole point: the Pages API column lines up with Express, not with the App Router.
| Trait | Node core | Express | Next App Router | Next Pages API |
|---|---|---|---|---|
| Foundation | Node streams | decorated Node streams | Web Fetch standard | decorated Node streams |
| Statefulness | mutable | mutable | return a value | mutable |
| URL parsing | raw req.url string | req.query / req.params | req.nextUrl object | req.query object |
| Headers | plain lowercased object | req.get() helper | Web Headers (.get()) | plain lowercased object |
| Cookies | raw header string | cookie-parser needed | built‑in .cookies | built‑in req.cookies |
| Body | drain the stream by hand | express.json() middleware | await req.json() | req.body (auto) |
| Send JSON | end(JSON.stringify()) | res.json() | NextResponse.json() | res.json() |
| Redirect | writeHead(302,…) | res.redirect() · 302 | NextResponse.redirect() · 307 | res.redirect(307,…) |
| Runtime | Node only | Node only | Node + Edge | Node only |
| Primary use | servers & frameworks | classic backend APIs | route handlers & middleware | legacy API routes |
08 Runtime & why it matters
The lineage decides where your code can run.
The two layers line up for everyone except the App Router. Raw Node, Express, and Next's Pages API all hand you Node streams, so they all need a real Node process. Only the App Router put on the Web platform's Request/Response, which is why its objects are immutable and the same handler can run in a bare V8 isolate at the Edge.
So the practical takeaway isn't "which API is nicer" — it's that decoration buys you the Node ecosystem, and extension buys you the Web platform and the Edge. Three of the four dialects chose decoration; one chose extension. You're picking a bloodline, and the ergonomics — and the runtimes you can deploy to — follow from it.