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

API ownership questions
DecisionNext.js Route HandlersExpress application
Primary applicationA React web application with its HTTP endpointsAn HTTP application composed from routes and middleware.
UI renderingPart of the surrounding Next.js frameworkChoose and integrate the UI/rendering approach separately.
Request programming modelWeb Request/Response and Next.js helpersExpress request/response objects and middleware chain.
Independent API releaseRequires a deliberate deployment boundaryNatural when Express is deployed as its own application.
Authorization and business rulesApplication responsibilityApplication responsibility.
Background processingChoose durable processing outside a fragile requestChoose 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.

Published catalogue fixture outcomes
RequestExpected statusResponse meaning
GET /api/items/1200The fictional notebook exists and is available.
GET /api/items/2200The fictional pencil exists but is unavailable.
GET /api/items/404404No item matches a valid ID.
GET /api/items/01400The identifier format is invalid.
GET /api/items/13503The simulated data source failed; no internal error text is disclosed.
HEAD /api/items/1200No response body.
POST /api/items/1405This 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.