Express takes Node's IncomingMessage and glues new keys onto the very same object — same instance, more properties. Next.js doesn't decorate anything; NextRequest extends Request — it subclasses an entirely different base: the Web platform's Fetch Request. The difference between the two is one character of TypeScript.
Read these three blocks top to bottom. Notice the relationship operator in each, and notice which base each one builds on.
// http.IncomingMessage — what the OS hands Node interface NodeReq extends stream.Readable { method?: string; url?: string; // raw path: "/users/7?tab=info" headers: Record<string, string | string[] | undefined>; }
// Same object. We only ADD to it. type ExpressReq = NodeReq & { query: Record<string, unknown>; // GETTER, derived from .url params: Record<string, string>; // ASSIGNED by the router body?: unknown; // ASSIGNED by middleware }; // req is STILL an IncomingMessage. url is STILL untouched.
// A new class built on a DIFFERENT base: the Web standard. class NextRequest extends Request { // Request = Fetch API, NOT Node readonly nextUrl: NextURL; // a parsed URL object readonly cookies: RequestCookies; // built-in cookie store } // nextReq is a Request. It is NOT an IncomingMessage.
Request, not a decorated Node object. That's why it runs on the Edge, where there is no Node IncomingMessage to decorate in the first place.Click any row. The same-named property arrives by a different mechanism in each world — and the colors cluster to prove the thesis: slate = the untouched base · green = Express decoration · blue = Next's Web lineage.
On the left, toggle Express's decoration on and off — watch the Node layer underneath stay exactly the same. On the right, the base itself is different.
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 → falseEverything above was req. The response is the mirror image: Express decorates Node's ServerResponse with conveniences, while NextResponse extends Response — subclassing the same Web base its request did.
// http.ServerResponse — you write bytes, then end() interface NodeRes extends stream.Writable { statusCode: number; // a mutable field setHeader(k: string, v: string): void; end(chunk?: unknown): void; // no .json(), no .send() }
// Same writable stream. Just friendlier verbs bolted on. type ExpressRes = NodeRes & { status(code: number): ExpressRes; // chainable setter json(body: unknown): ExpressRes; // stringify + set type + end cookie(n: string, v: string): ExpressRes; }; // res is STILL a ServerResponse. Still mutated, then ended.
// A subclass of the Web Response — an immutable value. class NextResponse extends Response { readonly cookies: ResponseCookies; // built-in cookie store static json(body, init?): NextResponse; // factory → returns one static redirect(url): NextResponse; static next(): NextResponse; // "continue" (middleware) } // You build one and RETURN it. You never poke a stream.
res.json(), res.status(), res.cookie() are sugar that ultimately call Node's setHeader / write / end. The lifecycle is identical to raw Node: mutate, then end..json, .redirect, .next) each return a finished response. The body and status are set at construction — there's no stream to write to.Same inspector, the other direction. Click a row. 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 construct 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. You return the value; you don't drive it to completion."Decorate vs extend" isn't just a metaphor — it's two literal JavaScript mechanisms. Express re-points the object's prototype at its own augmented one; Next's class … extends sets the chain at construction via super(). Follow the [[Prototype]] links.
// 1. Express makes its own request proto, // inheriting from Node's prototype: app.request = Object.create( http.IncomingMessage.prototype ); // 2. query/get()/etc. are defined on it Object.defineProperty(app.request, 'query', { get() { return parseQs(this.url); } }); // 3. per request, re-point the proto: Object.setPrototypeOf(req, app.request);
req instanceof IncomingMessage stays true because Node's prototype is still on the chain.// A normal ES class. The chain is fixed // the moment `new` runs. class NextRequest extends Request { constructor(input, init) { super(input, init); // becomes a real Request this.nextUrl = new NextURL(input); } } const req = new NextRequest(webRequest);
IncomingMessage, so req instanceof IncomingMessage is false — and there's nothing Node-specific to need 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; you reach for a different tool entirely.
// types/express.d.ts declare global { namespace Express { interface Request { // re-opening it! user?: User; // merged in } } } // now this is typed everywhere: app.use((req, _res, next) => { req.user = lookup(req); // ✓ no cast next(); });
@types/express adds query and body in the first place. The type system mirrors the runtime: an open interface you keep widening — & by another name.// You CAN'T `declare` extra props onto a // class instance the same way. Options: // A) a typed wrapper / discriminated value type Authed = { req: NextRequest; user: User }; // B) carry it out-of-band (no req mutation) import { AsyncLocalStorage } from 'async_hooks'; const ctx = new AsyncLocalStorage<User>(); // ...later: ctx.getStore() // the user
user onto the request, so request state stays predictable — at the cost of the casual extensibility Express gives you.This isn't a Node-vs-Next quirk — it's two classic ways to add capability to an object. Each makes 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 or destroyed.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.