Next.js vs Express: Only One of Them Renders
Express is a server you build on. Next.js is a rendering framework with a server attached. Choosing between them is really choosing how much you want to write yourself.
These two are compared because both are Node and both serve HTTP, which is roughly where the similarity ends. Express is a minimal HTTP framework you compose. Next.js is a rendering framework that happens to include a server.
The decision is not really a comparison. It is a question about what you are building.
| Express | Next.js | |
|---|---|---|
| What it gives you | A request pipeline | A render path |
| Routing | Whatever you write | File system, with layouts |
| Rendering | None | The entire point |
| Bundling the client | Not its job | Built in |
| Long-lived connections | Native | Awkward to impossible |
| What you assemble yourself | Most of a front end | Most of a back end |
If you are building an API and nothing else
Use Express, or Fastify, or Hono. Next.js route handlers exist and they work, but you would be taking on a rendering framework, a build step, a file-system router and a React dependency for a service that renders nothing.
Specifically, Express stays better when your API has:
- External consumers. Versioned routes, API keys, documented contracts.
- Its own middleware chain. Rate limiting, request validation, custom authentication, logging that belongs to you.
- Anything long-lived. WebSockets, server-sent events, connections that outlive a request.
- Deployment constraints. A plain Node process is a simpler thing to run on infrastructure that has opinions.
The middleware point deserves more than a bullet, because it is the one that
gets discovered late. An Express application is a pipeline you assemble: every
request passes through the functions you registered, in the order you
registered them, and you can reason about the whole chain by reading one file.
Next.js has nothing equivalent at the application level. proxy.ts - the
convention that replaced middleware in 16 - runs on a matcher, on requests you
may not have intended, and
is not a request pipeline. Trying to use it as
one is the most common way a team ports an Express habit into a codebase that
cannot support it.
If you are rendering pages, do not build it yourself
The reverse is stronger and less obvious.
Teams that start with Express and add React on top end up writing, by hand, the things Next.js already decided: routing, code splitting, data fetching on the server, streaming, the client bundle boundary, image handling, caching. Each is a week. Together they are a framework, and it will be a worse one, because it is a side project rather than the product.
The part that is underestimated is not any single item on that list. It is that these decisions interact. Code splitting has to know about routing. Streaming has to know about the data layer. The client boundary has to know about both. A framework is worth what it is worth because somebody made those choices consistent with each other, and a custom stack is where they slowly stop being consistent.
If your application has pages, the case for Express is the case for writing your own framework. That is occasionally correct and usually not.
Where route handlers are genuinely enough
For an API consumed only by your own front end, Next.js route handlers do the job:
// app/api/products/route.ts
export async function GET(request: Request) {
const products = await db.product.findMany();
return Response.json(products);
}One deployment, one codebase, shared types between the API and the components that call it. For most product applications this is the right amount of machinery.
Worth knowing: for data your own pages need, you frequently do not need a route at all. A Server Component can query directly, and a Server Action can mutate - the round trip through your own HTTP API is a habit carried over from a client-rendered architecture.
Where route handlers stop being enough
Three limits, in the order teams hit them.
The connection has to stay open. A route handler is built around a request that ends. Server-sent events are possible with a streamed response; a WebSocket is not, and on a serverless deployment target neither survives the function's lifetime. Anything with a persistent connection belongs in a process you control.
Something has to happen on a schedule. There is no job runner. A cron service hitting an authenticated endpoint covers the simple cases, and stops covering them the moment a job needs a retry policy, a dead letter queue, or more time than the platform's execution limit allows.
The API has consumers who are not you. Versioning, deprecation, per-client rate limits, an authentication story separate from your front end's session, and documentation that is a contract. Every one of those is buildable in route handlers and every one of them already exists in the ecosystem you would have chosen instead.
The arrangement most teams actually end up with
Express - or Fastify, or a Go service, or Laravel - holding the business logic and the jobs. Next.js holding the front end. They talk over HTTP.
This is the same conclusion as Next.js against Laravel, for the same reason: the render path and the business logic are different problems, and frameworks are good at one of them each.
The cost is real and worth stating: two deployment targets, a contract between them that somebody maintains, and types that have to be kept in sync across a network boundary rather than checked by one compiler. Teams that adopt this split without acknowledging that cost end up with a front end that reimplements half the API's rules to avoid a round trip.
What not to do
A custom Express server in front of Next.js. It is supported and it disables automatic static optimisation for the routes it fronts - which is most of what you came for. If you need a proxy, put one in front of both processes instead of inside one of them.
Porting an Express middleware chain into proxy.ts. The convention that
replaced middleware in 16 runs on Node and
is not configurable, and it runs on requests you
did not intend. It is for routing decisions, not for a request pipeline.
Choosing Express because you want control, on a project that is all pages. Control over the render path is what Next.js gives you; control over the request pipeline is what you would be buying, and on a rendering project that is the pipeline you were never going to need.
