There are only two ways to give a request object more power, and each framework picked one. Express takes Node's IncomingMessage and glues new keys onto the very same instance — same object, more properties. Next.js App Router doesn't touch Node's object at all: NextRequest is a subclass of the Web platform's Request. This page traces a request through all three lifecycles, then opens up the machinery underneath.
Additive. IncomingMessage & { … } — every Node property survives, Express just layers more on. req instanceof IncomingMessage stays true.
A new foundation. class NextRequest extends Request — a different lineage, which is why the same handler runs in a bare V8 isolate at the Edge.
Before any framework, Node's HTTP server parses the raw bytes and hands you a readable request and a writable response. Frameworks decide what to do with these two.
req›resWhat Node does not give you: req.params, a parsed req.query, an already-read req.body, res.json(), res.status(), routing, or middleware. That is the job frameworks take on — and the next section is the fork in how they take it.
There are three route styles, but only two lineages for the object your handler receives. The whole question is: does the public request descend from Node's IncomingMessage, or is it a Web Request?
Express and the older pages/api/* routes both receive Node's object, widened with extras. The body is a stream you (or middleware) drain; the response is a writable stream you mutate, then end().
App Router route.ts handlers receive a Web Request / NextRequest and return a Response. No Node object is involved — so the same handler runs on Node or at the Edge.
Pick a framework, then press run to send a request through every stage — or step through it. Each stage is colored by which world owns it; click any stage to read what happens there.
req property come from?Click any row. The same-named property arrives by a different mechanism in each world — and the badges cluster to prove the thesis: indigo = the untouched base, green = Express decoration, blue = Next's Web lineage.
On the left, toggle Express's decoration on and off — the Node layer underneath stays exactly the same. On the right, the base itself is different; there is no Node object hiding below.
req instanceof IncomingMessage → truereq.url after decoration → unchangedreq.query is a getter that reads req.url and parses it. It never writes back. Decoration only ever adds.nextReq instanceof Request → truenextReq instanceof IncomingMessage → falseres power come from?The same inspector, the other direction. Notice that Next's response verbs are mostly static factories (they build a value) where Express's are instance methods (they mutate one).
This is what the type difference causes. A decorated ServerResponse is a writable stream, so you mutate it step by step. A NextResponse is-a Web Response — an immutable value — so you build it whole and hand it back. Step through both.
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."Decorate vs extend" isn't only a metaphor — it's two literal JavaScript mechanisms. Express re-points the object's prototype at its own augmented one; Next's class … extends fixes the chain at construction via super(). Follow the [[Prototype]] links.
req instanceof IncomingMessage stays true.IncomingMessage, so req instanceof IncomingMessage is false — and nothing needs a Node runtime.req.user yourself.The runtime split has an exact mirror in TypeScript. Because Express decorates a shared interface, you extend it by re-opening it — declaration merging. Because NextRequest is a sealed class, there's no interface to merge into.
@types/express adds query and body. An open interface you keep widening — & by another name.user onto the request, so request state stays predictable.This isn't a Node-vs-Next quirk — it's two classic ways to add capability to an object, each making a different trade.
req instanceof IncomingMessage stays true, so every Node-based tool still works on it.query is a getter computed from url; nothing is overwritten.body, user, cookies) as the request flows.req.body is unknown until some middleware ran.NextRequest is-a Request — it inherits the whole Fetch contract for free.nextUrl and cookies are always there with known types — no "did the middleware run?" doubt.instanceof IncomingMessage is false.Request object, then response model.
| Style | Public request | Underlying model | Body access | Params / query |
|---|---|---|---|---|
| Express | req | Node IncomingMessage + extras | req.body after middleware | req.params, req.query |
| Next App Router | request | Web Request / NextRequest | await request.json() | arg params, request.nextUrl.searchParams |
| Next Pages API | req | Node IncomingMessage + extras | req.body via parser | req.query |
| Style | Public response model | How you respond |
|---|---|---|
| Express | Node ServerResponse + extras | res.status(200).json(data) |
| Next App Router | Web Response / NextResponse | return Response.json(data, { status: 200 }) |
| Next Pages API | Node ServerResponse + extras | res.status(200).json(data) |
Coupling the public API to Node's IncomingMessage ties you to a Node process. Modeling it as a Web Request / Response gives App Router a single abstraction that travels.