# 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