Lesson 73 of 81
Wrapping Next.js in your own Express server buys you full control over routing and middleware — and quietly opts you out of several optimizations the managed Next.js server was doing for you automatically.
13 min read
Every option explains itself — why the right one is right, and why each wrong one is wrong. Miss one and it comes back later on a spaced-repetition schedule.
This topic is about judgment and practical tradeoffs more than a single right answer — a mock interview is the better way to test yourself on it.
Be interviewed on this →Hints without spoilers, and a plain explanation of why a wrong answer was wrong — answered only from this lesson. Free account, no card.
Sign up freeWhat interviewers actually ask about this, and what a strong answer has to cover.
A strong answer covers
Likely follow-ups
By default, next start (or the managed server behind most hosting platforms) runs its own Node.js server tuned specifically for how Next.js works. A custom server replaces that with your own Node process — commonly Express, Fastify, or plain http.createServer — into which you manually mount Next.js's request handler. You'd reach for this when you need something the managed server genuinely can't do: attaching a WebSocket server on the same port, running Next.js as one route group inside a larger existing Node app, or wiring in server middleware that has to run before Next.js ever sees the request.
The part that's easy to miss: several optimizations exist specifically because Next.js controls the whole server process. Handing control to your own server code means those optimizations either need to be reimplemented by hand or simply stop applying.
// server.ts
import express from "express";
import next from "next";
const dev = process.env.NODE_ENV !== "production";
const app = next({ dev });
const handle = app.getRequestHandler();
app.prepare().then(() => {
const server = express();
// Custom middleware that runs before Next.js ever sees the request —
// this is the actual reason to reach for a custom server.
server.use((req, res, next) => {
console.log(`${req.method} ${req.url}`);
next();
});
server.get("/healthz", (req, res) => {
res.status(200).send("ok");
});
// Everything else falls through to Next.js's own router.
server.all("*", (req, res) => handle(req, res));
server.listen(3000, () => {
console.log("ready on http://localhost:3000");
});
});
This works, and the /healthz route and the logging middleware genuinely need a layer in front of Next.js to exist. But this app now starts with node server.js (or a tsx/build step of your own) instead of next start, and that ownership change is where the tradeoffs start.
fetch caching, and most rendering behavior keep working when mounted inside a custom server via getRequestHandler(). What you lose is specifically the parts tied to Next.js controlling the outer server process and deployment shape, not the framework's core rendering model.This is the same shape of decision as reaching for dangerouslySetInnerHTML in React, or dropping to raw SQL when an ORM's query builder can't express something — the framework's managed path gives you optimizations and simplicity in exchange for giving up some control, and the escape hatch reverses that trade. The right call depends entirely on whether the thing you need really can't be done any narrower way, which is worth checking hard before committing an entire app to running its own server process.