# Next.js and Express API evaluation
Reviewed 30 September 2026. This is a disposable local example and an assessment worksheet, not a production API template.
## Executed scope
The example uses fictional public catalogue data. It was built and tested with Node.js 24.19.0, npm 11.9.0, Next.js 16.3.7, Express 5.2.1 and React/React DOM 19.3.0. These versions identify the checked environment; they are not an evergreen recommendation.
The production Next.js server and Express server each passed seven HTTP tests: five GET cases, HEAD with an empty body and POST rejected with 405. The GET cases check status, JSON content type, `Cache-Control: no-store`, the complete response and absence of an internal error string. ID 13 intentionally triggers the simulated failure. There is no real database or external API.
Not tested: authentication, writes, uploads, webhooks, persistence, concurrency, performance, browser rendering, a hosting provider or NestJS. The final Express error middleware is not independently fault-injected; the deliberate catalogue failure is handled by the shared function. The worksheet below describes additional work, not completed validation.
## Run in a new disposable directory
Create the seven files below with their displayed relative paths. Download [the exact package lock](https://nomadicsoft.io/downloads/next-express-fixture-package-lock.json) and save it beside `package.json` as `package-lock.json`. Use Node.js 24.19.0 and npm 11.9.0 to repeat the checked environment. The fixture never changes the Nomadicsoft website's application dependencies.
```sh
npm ci --ignore-scripts --no-audit --no-fund
NEXT_TELEMETRY_DISABLED=1 npm run build
npm test
```
The test runner starts both servers on loopback, selects available ports and stops the servers afterwards. Run from the directory containing `package.json`. Do not expose the sample publicly. `--ignore-scripts` matched this Linux verification; another platform may require its own dependency installation review.
## Complete example files
### `package.json`
```json
{
"name": "next-express-contract-fixture",
"private": true,
"type": "module",
"scripts": {
"build": "next build --webpack",
"test": "node --test contract.test.mjs"
},
"dependencies": {
"express": "5.2.1",
"next": "16.3.7",
"react": "19.3.0",
"react-dom": "19.3.0"
}
}
```
### `lib/catalogue.mjs`
```javascript
// Fictional public data; ID 13 deliberately simulates repository failure.
const items = new Map([
['1', { id: '1', name: 'Example notebook', available: true }],
['2', { id: '2', name: 'Example pencil', available: false }],
]);
export async function readItem(id) {
if (id === '13') throw new Error('fixture-internal-secret');
return items.get(id) ?? null;
}
export async function itemResponse(id) {
if (!/^[1-9][0-9]{0,8}$/.test(id)) {
return { status: 400, body: { error: 'invalid_id' } };
}
try {
const item = await readItem(id);
return item
? { status: 200, body: { item } }
: { status: 404, body: { error: 'not_found' } };
} catch {
return { status: 503, body: { error: 'catalogue_unavailable' } };
}
}
```
### `app/api/items/[id]/route.js`
```javascript
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' },
});
}
```
### `express-app.mjs`
```javascript
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;
}
```
### `app/layout.jsx`
```jsx
export default function RootLayout({ children }) {
return
{children};
}
```
### `app/page.jsx`
```jsx
export default function Page() {
return Fictional public catalogue contract fixture.;
}
```
### `contract.test.mjs`
```javascript
import assert from 'node:assert/strict';
import { before, after, test } from 'node:test';
import { spawn } from 'node:child_process';
import { once } from 'node:events';
import net from 'node:net';
import { createApp } from './express-app.mjs';
let expressServer;
let nextProcess;
let output = '';
const origins = {};
async function unusedPort() {
const socket = net.createServer();
socket.listen(0, '127.0.0.1');
await once(socket, 'listening');
const { port } = socket.address();
await new Promise((resolve) => socket.close(resolve));
return port;
}
async function request(origin, path, options = {}) {
return fetch(origin + path, {
...options, signal: AbortSignal.timeout(5000), redirect: 'manual',
});
}
before(async () => {
expressServer = createApp().listen(0, '127.0.0.1');
await once(expressServer, 'listening');
origins.express = `http://127.0.0.1:${expressServer.address().port}`;
const port = await unusedPort();
origins.next = `http://127.0.0.1:${port}`;
nextProcess = spawn(process.execPath, [
'node_modules/next/dist/bin/next', 'start', '-H', '127.0.0.1', '-p', String(port),
], { env: { ...process.env, NEXT_TELEMETRY_DISABLED: '1' }, stdio: ['ignore', 'pipe', 'pipe'] });
for (const stream of [nextProcess.stdout, nextProcess.stderr]) {
stream.on('data', (chunk) => { output = (output + chunk).slice(-8000); });
}
for (let attempt = 0; attempt < 100; attempt++) {
if (nextProcess.exitCode !== null) throw new Error('Next exited: ' + output);
try {
const response = await request(origins.next, '/api/items/1');
await response.text();
if (response.status === 200) return;
} catch {}
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw new Error('Next did not become ready: ' + output);
});
after(async () => {
if (expressServer) {
expressServer.closeAllConnections();
await new Promise((resolve) => expressServer.close(resolve));
}
if (nextProcess && nextProcess.exitCode === null) {
const exited = once(nextProcess, 'exit');
nextProcess.kill('SIGTERM');
const timer = setTimeout(() => nextProcess.kill('SIGKILL'), 5000);
await exited;
clearTimeout(timer);
}
});
const cases = [
['1', 200, { item: { id: '1', name: 'Example notebook', available: true } }],
['2', 200, { item: { id: '2', name: 'Example pencil', available: false } }],
['404', 404, { error: 'not_found' }],
['01', 400, { error: 'invalid_id' }],
['13', 503, { error: 'catalogue_unavailable' }],
];
for (const name of ['express', 'next']) {
for (const [id, status, expected] of cases) {
test(`${name}: GET ${id} returns ${status}`, async () => {
const response = await request(origins[name], '/api/items/' + id);
assert.equal(response.status, status);
assert.equal(response.headers.get('cache-control'), 'no-store');
assert.match(response.headers.get('content-type'), /^application\/json\b/);
const text = await response.text();
assert.deepEqual(JSON.parse(text), expected);
assert.equal(text.includes('fixture-internal-secret'), false);
});
}
test(`${name}: HEAD has no body`, async () => {
const response = await request(origins[name], '/api/items/1', { method: 'HEAD' });
assert.equal(response.status, 200);
assert.equal(await response.text(), '');
});
test(`${name}: POST is rejected`, async () => {
const response = await request(origins[name], '/api/items/1', { method: 'POST' });
assert.equal(response.status, 405);
await response.text();
});
}
```
## One-endpoint assessment worksheet
Complete this for an actual endpoint before moving it. Blank fields mean missing evidence, not approval.
| Question | Record |
| --- | --- |
| Endpoint, methods and owner | |
| Known web, mobile and integration callers | |
| Business reason for changing the implementation | |
| Current status, headers, body shape and cache policy | |
| Identity source and resource/tenant authorization | |
| Middleware order and short-circuit behavior | |
| Input formats, body limits and raw webhook requirements | |
| Data owner, state transitions and concurrent writes | |
| External dependencies, timeouts and uncertain outcomes | |
| Durable jobs, filesystem and runtime requirements | |
| Allowed, denied, invalid, missing and unavailable cases | |
| Target-host checks still required | |
| Read-only rollout scope and observation period | |
| Single-writer rule and operation identity for writes | |
| Stop conditions, rollback route and responsible person | |
| Known callers still using the old implementation | |
| Decision: move, keep, or investigate further | |
Do not duplicate production write requests merely to compare implementations. Establish isolated fixtures, a single writer and a reconciliation plan first. An HTTP timeout can occur after a dependency has committed a change.
## Related guides
- [Where Next.js code runs](https://nomadicsoft.io/blog/unraveling-next-js-is-it-a-frontend-or-backend-framework)
- [Next.js versus Express](https://nomadicsoft.io/blog/next-js-vs-express-js-choosing-the-best-javascript-framework-for-backend-development)
- [Express-to-Next.js migration assessment](https://nomadicsoft.io/blog/comparing-next-js-vs-express-for-backend-development-which-framework-to-choose)
- [Next.js versus NestJS](https://nomadicsoft.io/blog/servnext-js-vs-nest-js-difference-between-next-js-and-nestjs)
## Primary references
- https://nextjs.org/docs/app/api-reference/file-conventions/route
- https://nextjs.org/docs/app/guides/backend-for-frontend
- https://nextjs.org/docs/app/guides/data-security
- https://expressjs.com/en/guide/routing.html
- https://expressjs.com/en/guide/error-handling.html
- https://expressjs.com/en/guide/migrating-5.html