Blog

NextJS Examples | Express

Key Takeaways

  • A Next.js custom Express server is an escape hatch for a specific Express requirement, not the default way to add APIs.
  • Register Express middleware and routes first, then delegate remaining requests to Next.js with its request handler.
  • Express 5 requires named wildcards. Use /{*splat} when the fallback must include the root URL.
  • This setup runs as a Node.js server, needs its own production startup and shutdown handling, and cannot use Next.js standalone output.

You can put Express in front of Next.js: Express handles the routes you register, and Next.js handles the remaining requests. Use a Next.js custom Express server when you need an Express-specific middleware contract or must preserve an existing routing stack—not simply because your application needs an API. The Next.js custom-server guide recommends this approach only when its integrated router cannot meet your requirements.

Do You Need Express with Next.js?

Choose the smallest integration that satisfies your requirement:

RequirementStart with
An API in an App Router applicationA Next.js Route Handler
An API in a Pages Router applicationA Pages Router API Route
Map one URL to another or forward to an existing backendNext.js rewrites
Preserve middleware that specifically expects Express request, response, and next objectsEvaluate a custom Express server

Route Handlers use Web Request and Response APIs; Pages Router API Routes provide the corresponding API mechanism for pages projects. For an ordinary JSON endpoint, this App Router file is enough:

// app/api/posts/route.js
export function GET() {
  return Response.json([{ id: 1, title: 'First post' }]);
}

For URL mapping, check rewrites before taking over the server. Request interception may also fit Next.js Proxy; in Next.js 16, the older Middleware file convention is deprecated and renamed to Proxy. Proxy is not a drop-in host for Express middleware.

An existing Express backend can also remain a separate service. You don't need to wrap Next.js in Express just to call that backend.

Setting Up the Express Server

Use an Existing Next.js Application

The example targets Next.js 16.4.2, Express 5.3.0, and cross-env 10.1.0, running on Node.js 24 LTS. Review newer patches before deploying. Start with a working Next.js application and its compatible React dependencies, as outlined in the Next.js starter tutorial.

npm install --save-exact next@16.4.2 express@5.3.0 cross-env@10.1.0

Commit the updated package manifest and lockfile. The server files below use .mjs, which explicitly selects ECMAScript modules without changing your project's package-wide module setting.

Create the Posts Router

Create api/post.mjs at the project root. This is an ordinary Express module, not a Next.js API route:

// api/post.mjs
import express from 'express';

const posts = express.Router();

posts.get('/', (req, res) => {
  res.json([{ id: 1, title: 'First post' }]);
});

posts.post(
  '/',
  express.json({ limit: '16kb' }),
  express.urlencoded({
    extended: false,
    limit: '16kb',
    parameterLimit: 20,
  }),
  (req, res) => {
    const title = req.body?.title;
    if (typeof title !== 'string' || !title.trim() || title.length > 200) {
      return res.status(400).json({ error: 'Invalid title' });
    }

    // Demonstration only: validate and echo, without persisting a post.
    res.json({ title: title.trim() });
  },
);

// Express owns the entire /posts namespace in this example.
posts.use((req, res) => {
  res.status(404).json({ error: 'Post endpoint not found' });
});

export default posts;

The router exports exactly what the server imports. Its GET endpoint returns sample data; POST validates and echoes a title without saving anything. The final middleware deliberately returns a JSON 404 for unmatched requests under /posts.

Express 4.16.0 introduced the built-in express.json() and express.urlencoded() middleware, so you usually don't need a separate body-parser dependency for JSON or URL-encoded forms. These parsers populate Express's req.body; they aren't Next.js request parsing. Scope them to the Express endpoints that need them rather than consuming request bodies before handing them to Next.js.

The body-size and form-parameter limits above are explicit choices for this example. If you need a refresher on execution order, review how Express middleware works.

Create the Custom Server Entry Point

Create server.mjs at the project root:

// server.mjs
import { once } from 'node:events';
import express from 'express';
import next from 'next';
import posts from './api/post.mjs';

async function main() {
  const port = Number(process.env.PORT ?? 3000);
  if (!Number.isInteger(port) || port < 1 || port > 65535) {
    throw new Error('PORT must be an integer between 1 and 65535');
  }

  const hostname = process.env.HOST ?? '0.0.0.0';
  const dev = process.env.NODE_ENV !== 'production';
  const nextApp = next({ dev, hostname, port });
  const handle = nextApp.getRequestHandler();

  await nextApp.prepare();

  const app = express();
  let draining = false;

  app.get('/healthz', (req, res) => {
    res.status(draining ? 503 : 200).json({ ok: !draining });
  });

  app.use('/posts', posts);

  app.all('/{*splat}', async (req, res) => {
    await handle(req, res);
  });

  app.use((error, req, res, nextMiddleware) => {
    console.error(error);
    if (res.headersSent) return nextMiddleware(error);

    const status = Number.isInteger(error.status)
      && error.status >= 400 && error.status < 500
      ? error.status
      : 500;

    res.status(status).json({
      error: status === 500 ? 'Internal server error' : 'Invalid request',
    });
  });

  const server = app.listen(port, hostname);
  await once(server, 'listening');
  console.log(`Listening on port ${port}`);

  async function shutdown(signal) {
    if (draining) return;
    draining = true;
    console.log(`${signal}: draining requests`);

    const deadline = setTimeout(() => {
      console.error('Shutdown deadline exceeded');
      process.exit(1);
    }, 10000);

    try {
      await new Promise((resolve, reject) => {
        server.close((error) => error ? reject(error) : resolve());
      });
      await nextApp.close();
      clearTimeout(deadline);
      process.exit(0);
    } catch (error) {
      console.error(error);
      process.exit(1);
    }
  }

  for (const signal of ['SIGINT', 'SIGTERM']) {
    process.once(signal, () => { void shutdown(signal); });
  }
}

main().catch((error) => {
  console.error(error);
  process.exit(1);
});

next({ dev, hostname, port }) creates the Next.js application. Awaiting nextApp.prepare() completes its preparation before Express starts listening; getRequestHandler() supplies the function used for the handoff.

Express 5 forwards rejected promises from async handlers into error middleware. The final error handler preserves client-error status codes without exposing exception details; if a response has already started, it delegates error handling instead of attempting a second response. See Express error-handling patterns for a deeper treatment.

How Next.js Custom Server Routing Works

Request
  |
  v
Express middleware and routes
  |-- /healthz ----------> health response
  |-- /posts ------------> posts router, including its own 404
  |
  v
/{*splat} fallback
  |
  v
Next.js request handler
  |-- App Router or Pages Router routes
  |-- framework endpoints and assets
  `-- Next.js 404 when nothing matches

Order determines ownership. A middleware that sends a response ends that request; middleware that calls its continuation function allows later handlers to run. Here, the posts router owns /posts, while the catch-all delegates everything else to Next.js. The fallback does not merely render pages: it also leaves Next.js responsible for its assets and other framework routes.

app.all() matches all HTTP methods. Express 5 requires a named wildcard: /*splat excludes /, while /{*splat} includes it. The older app.all('*', ...) pattern assumes Express 4 and is not valid Express 5 syntax.

Forwarding Requests to the Post Page

For page forwarding, avoid nextApp.render(req, res, '/post'). That custom-server method is deprecated in Next.js 16.4.2; its replacement guidance is to use the request handler with an adjusted parsed URL. For an ordinary request to /post, the catch-all already handles everything—no dedicated Express route is necessary.

The page location depends on your router. Put this component in pages/post.js for the Pages Router or app/post/page.js for the App Router, not both. For a project using src, use the corresponding directory beneath src:

// pages/post.js OR app/post/page.js; choose your project's router.
export default function Post() {
  return <main>Post page</main>;
}

If an existing Express integration must map another incoming URL to that page, add this fragment inside main(), before the catch-all:

// Fragment: inside main(), before app.all(...).
app.get('/legacy-post', async (req, res) => {
  const url = new URL(req.url, 'http://localhost');
  await handle(req, res, {
    pathname: '/post',
    search: url.search,
  });
});

This preserves the query string while selecting /post as the Next.js destination. Prefer a Next.js rewrite when URL mapping is your only requirement.

Updating package.json

Merge these scripts into your existing package.json, retaining any configured test and lint scripts:

{
  "scripts": {
    "dev": "cross-env-shell NODE_ENV=development node server.mjs",
    "build": "next build",
    "start": "cross-env-shell NODE_ENV=production node server.mjs"
  }
}

cross-env makes the environment-variable assignment work across Windows and POSIX shells. It's installed as a runtime dependency here because the production start script uses it. next build builds the Next.js application; it does not compile or bundle your custom server. Restart the process after changing server.mjs or its Express modules.

Deploying a Next.js App with a Custom Express Server

Choose a VM or container hosting model that can run your long-lived Node.js process. Don't assume a managed deployment that builds individual framework routes will execute this entry point; confirm custom-process support with your provider.

  1. Build with the lockfile and development dependencies available.
  2. Deploy the build together with the custom server, router modules, configuration, public assets, and runtime dependencies.
  3. Launch server.mjs in production mode through a process supervisor or container runtime.
  4. Configure health checks, logging, restart behavior, and signal forwarding.

For a simple deployment that builds in its target environment:

npm ci
npm run build
npm ci --omit=dev
npm start

Run the first installation with development dependencies enabled. The second installation removes development dependencies from the installed tree; it does not replace the preceding build step. Starting with next start instead would bypass your Express entry point.

Performance warnings are version-specific. The Next.js 15 guide warns about losing optimizations such as Automatic Static Optimization; the current Next.js 16 guide no longer includes that blanket warning. That documentation difference isn't a performance guarantee: regression-test your rendering, caching, and client navigation.

The example stops accepting connections, waits for active HTTP requests, and closes the Next.js application when it receives SIGINT or SIGTERM. Its shutdown deadline is 10 seconds, chosen for demonstration. Align that deadline with your host's termination window, add cleanup for database connections, and explicitly drain any upgraded connections you introduce.

Common Mistakes

  • Parsing every body globally: keep Express parsers away from requests owned by Next.js, including Route Handler and Server Action requests.
  • Shadowing framework routes: broad Express routes registered before the fallback can intercept pages or assets unintentionally.
  • Setting headers after the handoff: set headers you own before calling the Next.js handler, not after awaiting it.
  • Assuming the sample saves posts: the POST endpoint only validates and echoes input. Add persistence and access controls deliberately.
  • Treating health as application correctness: a responding process doesn't prove its database or other dependencies are ready.

Production Checklist

  • Put a reverse proxy in front of the process and configure TLS, request limits, and rate limiting. Follow the Next.js self-hosting guidance.
  • Validate untrusted input and protect Express endpoints independently. Review these Express security practices.
  • Test /, /post, /posts, unknown paths, malformed JSON, and oversized payloads in a production build.
  • Monitor request latency, failures, memory, and shutdown time. Confirm that deployments actually deliver termination signals to Node.js.
  • Before adding replicas, review shared caching and deployment consistency rather than assuming each process can operate independently.

FAQ

Can you use Express with Next.js?

Yes. A custom Express server can handle its own middleware and routes, then delegate remaining requests to the Next.js request handler. Use it for a concrete Express requirement rather than as the default architecture.

Should I use Express or Next.js Route Handlers for an API?

Use Route Handlers for ordinary APIs in an App Router project, or API Routes in a Pages Router project. Choose Express when you need to preserve an Express-specific integration that those built-in mechanisms cannot cleanly replace.

Does a Next.js custom server work with standalone output?

No. Next.js standalone output does not trace custom-server files and generates its own minimal server. Deploy this example as a regular Node.js application with its custom entry point and runtime dependencies.

How do you forward unmatched Express routes to Next.js?

Prepare the Next.js application before listening, obtain its handler with nextApp.getRequestHandler(), and register the fallback after your Express routes. Pass the original request and response objects to that handler.

What wildcard syntax works with Express 5?

Use /{*splat} to match every path, including /. The alternative /*splat excludes the root path, and an unnamed '*' wildcard is not valid Express 5 route syntax.

Further Reading

Written by Sam Erickson

Software engineer. I write every article on StackChief myself. More about me