Choose Next.js Route Handlers when the HTTP layer belongs closely to a React web application; evaluate Express when the API needs its own routing, middleware and release boundary. Either can return JSON. That shared capability is a starting point for a comparison, not evidence that the rest of their responsibilities are interchangeable.
The decision should begin with callers and operations. Is the API used only by this web UI, or also by a mobile app and external partners? Can the API and website ship together? Who owns authentication, data access, jobs and incident recovery? Answer those questions before counting framework features.
Compare the responsibilities you will operate
| Decision | Next.js Route Handlers | Express application |
|---|---|---|
| Primary application | A React web application with its HTTP endpoints | An HTTP application composed from routes and middleware. |
| UI rendering | Part of the surrounding Next.js framework | Choose and integrate the UI/rendering approach separately. |
| Request programming model | Web Request/Response and Next.js helpers | Express request/response objects and middleware chain. |
| Independent API release | Requires a deliberate deployment boundary | Natural when Express is deployed as its own application. |
| Authorization and business rules | Application responsibility | Application responsibility. |
| Background processing | Choose durable processing outside a fragile request | Choose durable processing outside a fragile request. |
The Route Handler reference covers the App Router interface. The Express routing guide covers its routing model. Neither framework selection decides your data ownership or automatically makes an endpoint secure.
Start with the same observable contract
For a small exercise, both implementations serve fictional public catalogue items at GET /api/items/:id. The contract specifies status, response fields and cache behavior. IDs are positive decimal strings of at most nine digits, without leading zeros. Item 13 deliberately triggers a fixture failure; it is not a hidden production diagnostic.
| Request | Expected status | Response meaning |
|---|---|---|
| GET /api/items/1 | 200 | The fictional notebook exists and is available. |
| GET /api/items/2 | 200 | The fictional pencil exists but is unavailable. |
| GET /api/items/404 | 404 | No item matches a valid ID. |
| GET /api/items/01 | 400 | The identifier format is invalid. |
| GET /api/items/13 | 503 | The simulated data source failed; no internal error text is disclosed. |
| HEAD /api/items/1 | 200 | No response body. |
| POST /api/items/1 | 405 | This read-only endpoint rejects the method. |
Every tested GET response uses Cache-Control: no-store. That is a deliberate fixture choice, not a recommendation to disable caching for every public catalogue. Production caching needs an explicit freshness and invalidation policy. This example has no login, database, personal data or write operation.
Next.js: adapt the request to a small data function
The handler below is the file app/api/items/[id]/route.js from the tested example. It awaits dynamic parameters, calls a shared function and constructs a response. The function's complete implementation is included in the download.
import { itemResponse } from '../../../../lib/catalogue.mjs';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export async function GET(_request, context) {
const { id } = await context.params;
const result = await itemResponse(id);
return Response.json(result.body, {
status: result.status,
headers: { 'Cache-Control': 'no-store' },
});
}
The example uses the Node.js runtime and deliberately dynamic processing. Review caching and runtime configuration against the project's selected Next.js version. The function returns a response contract; it does not assume that anything called “server code” is inaccessible to clients.
Express: keep routing and error behavior explicit
The Express application uses the same data function. The GET handler also supplies HEAD behavior; a later route rejects other methods for this path. A final error handler returns a generic response for unexpected middleware errors.
import express from 'express';
import { itemResponse } from './lib/catalogue.mjs';
export function createApp() {
const app = express();
app.disable('x-powered-by');
app.get('/api/items/:id', async (request, response) => {
const result = await itemResponse(request.params.id);
response.set('Cache-Control', 'no-store')
.status(result.status).json(result.body);
});
app.all('/api/items/:id', (_request, response) => {
response.status(405).end();
});
app.use((_error, _request, response, _next) => {
response.status(500).json({ error: 'internal_error' });
});
return app;
}
The sample uses Express 5, whose error-handling guide explains rejected promises in route handlers. Review older Express 4 middleware separately instead of assuming its error propagation or route matching is identical. The final handler is a minimal fixture response, not a production logging or incident-management implementation.
What the executed comparison establishes
We built the isolated Next.js application and ran the same HTTP cases against its production server and a real Express server bound to loopback. All 14 tests passed. The fixture used Node.js 24.19.0, Next.js 16.3.7, Express 5.2.1 and React/React DOM 19.3.0. These identify the checked environment, not an evergreen version recommendation.
The checks compare each GET's status, JSON content type, explicit cache header and complete body. They also reject disclosure of the simulated internal error string, confirm HEAD has no body and confirm POST returns 405. Download the complete example, test runner and decision worksheet to repeat it in a disposable directory.
This does not measure speed, memory, concurrency, authentication, persistent storage, deployment-provider behavior or browser rendering. Two implementations satisfying a tiny read contract are not thereby interchangeable for a production application. The result gives the team a repeatable comparison pattern.
Choose the deployment boundary from a real constraint
For a portal whose API mainly composes data for its own pages, keeping Route Handlers with the UI can reduce the number of separately released pieces. Keep domain rules in ordinary server-side modules so they can be tested without rendering a page. Do not add a second service solely to make the diagram look more complete.
For an API used by several applications, an independent service can make versioning, ownership and rollout clearer. Express is one option for that boundary. It also creates work: service authentication, timeouts, tracing, deployment and compatibility when callers upgrade at different times.
A framework name does not predict throughput. Measure a representative request with the same data source, authentication, payload, cache policy and deployment constraints. A benchmark that compares cached content on one side with live database work on the other answers the wrong question.
Make the decision reviewable
Record who calls the API, what must remain compatible, how writes recover from uncertainty and who operates it after release. If a single Next.js application satisfies those constraints, document that. If an independent API is justified, name the requirement that pays for the extra boundary.
Already have an Express API? Our Express-to-Next.js migration assessment inventories compatibility and staged cutover instead of repeating this framework-selection comparison. If you need a more structured Node service, compare Next.js and NestJS. For the execution model itself, see where Next.js code runs.
