Skip to main content
ASoc
Tutorial

Next.js Custom Server: Five Jobs It Is Asked To Do, and Where They Belong

What ejecting costs, measured: 846 prerendered routes on the line, plus the five jobs people reach for a custom server for and the file that owns each.

The ASoc Team9 min read

A Next.js custom server replaces next start with your own Node process that calls next() and hands requests to app.getRequestHandler(). It is rarely the right move: Next's own docs say it removes Automatic Static Optimization, and it cannot be combined with standalone output. Headers, webhooks, auth cookies and file streaming all have first-class homes instead.

This storefront runs 850 prerendered routes on Next 16.2.9 with no server.js, no createServer(, and no node:http import anywhere in src/. Its package.json scripts are plain next dev / next build / next start. Below is what the custom-server escape hatch actually is, the measured cost of taking it, and the file that does each job people usually reach for it to solve.

What a custom server is

The bundled docs for the installed version (node_modules/next/dist/docs/01-app/02-guides/custom-server.md) are blunt about it:

Next.js includes its own server with next start by default. [...] A custom Next.js server allows you to programmatically start a server for custom patterns. The majority of the time, you will not need this approach. However, it's available if you need to eject.

The shape is about fifteen lines — you own the HTTP server, Next owns the rendering:

import { createServer } from "http";
import next from "next";

const app = next({ dev: process.env.NODE_ENV !== "production" });
const handle = app.getRequestHandler();

app.prepare().then(() => {
  createServer((req, res) => handle(req, res)).listen(3000);
});

next() takes conf, dev, dir, quiet, hostname, port, httpServer, turbopack (on by default in 16) and webpack. One detail in that doc is easy to miss and expensive to discover: server.js does not run through the Next.js Compiler or bundling process, so its syntax has to be valid for your Node version as written. No TypeScript, no path aliases, no transforms — the one file in your app that is not compiled.

Two more constraints from the same page:

  • It "will remove important performance optimizations, like Automatic Static Optimization."
  • "When using standalone output mode, it does not trace custom server files. This mode outputs a separate minimal server.js file, instead. These cannot be used together." If you containerise with output: "standalone", a custom server is not an option.

useFileSystemPublicRoutes, the escape valve for serving the same content at two paths, is Pages-Router only — the current doc wraps that section in <PagesOnly>.

The measured cost of ejecting

"Removes Automatic Static Optimization" is abstract until you count what is static. From this repository's last next build, read out of .next/prerender-manifest.json:

Prerendered at build timeCount
Blog post pages355
Product pages (/templates/[slug])111
Generated OG cards356
Home, hubs, docs, legal, feed, sitemap, robots28
Total prerendered routes850
Rendered on demand (ƒ)8

The eight dynamic ones are the account surfaces that cannot be static: /login, /signup, /reset-password, /dashboard, /dashboard/settings, /auth/callback, and the two API handlers. Everything else is HTML on disk, which is the entire reason the marketing pages hold a 100 desktop Lighthouse performance score.

So the trade is 850 files served flat versus 850 renders per request, bought to gain control of a port. That is the number to put next to whatever the custom server was meant to solve.

The five jobs, and where each belongs

Every custom server in a Next codebase I have read was there for one of five reasons. Each has a home that keeps the static build:

You want toCustom-server instinctWhere it belongsIn this repo
Set security headers / CSPres.setHeader in Expressheaders() in next.config.tsnext.config.ts (121 lines, CSP + 4 headers)
Refresh an auth cookie per requestExpress session middlewareproxy.ts (Next 16's middleware)src/proxy.ts, 42 lines
Verify a webhook over the raw bodyexpress.raw()Route handler reading req.text()src/app/api/webhooks/lemonsqueezy/route.ts
Stream a private fileres.pipe(fs.createReadStream(...))Route handler issuing a signed URLsrc/app/api/download/route.ts
Serve XML / non-HTMLA hand-rolled Express routeRoute handler returning a Responsesrc/app/blog/feed.xml/route.ts

Four route handlers and one proxy file cover all five. Here is what two of them look like.

Headers belong in the config, not the request path

next.config.ts applies one header set to every route, static ones included — something an Express layer in front of a static export cannot do without putting itself in the hot path of every asset:

const securityHeaders = [
  { key: "Content-Security-Policy", value: contentSecurityPolicy },
  { key: "X-Content-Type-Options", value: "nosniff" },
  { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
  { key: "X-Frame-Options", value: "DENY" },
  {
    key: "Strict-Transport-Security",
    value: "max-age=63072000; includeSubDomains",
  },
];

const nextConfig: NextConfig = {
  async headers() {
    return [{ source: "/(.*)", headers: securityHeaders }];
  },
};

The CSP string itself is built at config-evaluation time from NEXT_PUBLIC_SUPABASE_URL, so connect-src is correct per environment without a runtime branch. Zero per-request cost.

The raw-body webhook, without express.raw()

The most common genuine reason to want Express is a signature over exact bytes. A body parser that has already JSON-parsed the payload has destroyed the thing you need to hash. In a route handler you just do not parse it:

export const runtime = "nodejs";

export async function POST(req: Request) {
  const raw = await req.text();
  const signature = req.headers.get("X-Signature");
  // HMAC is computed over `raw` — re-serializing a parsed object can
  // byte-for-byte differ from what the sender signed.
}

req.text() is the whole trick. The comment in that file states the reason in the repo's own words: the HMAC must be computed "over the exact bytes LS signed." runtime = "nodejs" is declared explicitly because the handler needs node:crypto — a Node runtime is a one-line declaration, not a reason to own the server.

The gated download is the other half of the same pattern. src/app/api/download/route.ts never streams bytes itself; it authorises, then redirects to a short-lived signed Storage URL, so no application process sits in the middle of a zip transfer.

Putting work in the request path has a blast radius

One thing a custom server does guarantee: all of your code is now in front of all of your traffic. This repo has a documented instance of that hazard at the one place it does run per-request code. src/proxy.ts reads its configuration with non-null assertions:

const supabase = createServerClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
  /* … */
);

If those variables are unset, the proxy throws, and because it matches nearly every path, the whole site errors — marketing pages that never touch a session included. That is why this project's CLAUDE.md carries a standing warning not to deploy main to production until the Supabase variables exist in the Vercel project. Forty-two lines of plumbing, one unset variable, every route down.

Now scale that surface from one small file to an entire Express app, and add the fact that it is the one file Next does not compile or typecheck. The matcher-and-handler split exists so a mistake costs one route; a custom server removes the split. The auth-middleware post covers how that split is drawn here, and the Next 16 proxy rename covers the file mechanics.

When a custom server is genuinely the answer

It is an escape hatch, and some requirements genuinely need it:

  • A long-lived socket server in the same process — a WebSocket or Socket.IO server sharing a port with your app. Next has no built-in equivalent; the httpServer option exists for exactly this.
  • Mounting Next inside an existing backend that owns routing, auth and middleware you are not going to rewrite. Note the docs' distinction: keeping a separate backend and calling it is not a custom server.
  • Protocol-level work below HTTP routing — custom TLS termination, a non-HTTP listener on the same process.

What they share: they are about owning the listener, not about anything a request does once routed. If the requirement can be phrased as "on each request, do X," it is a proxy or a route handler. If it is "hold this connection open," it may be a server.

And check the deployment target first. A custom server means you run and scale the Node process yourself. Platform builds that expect next start will never execute your server.js, and the standalone output that container deployments rely on cannot include it.

Mistakes and how they show up

MistakeSymptomFix
Custom server to set headers850 pages render per requestheaders() in next.config.ts
TypeScript or path aliases in server.jsRuntime SyntaxError / unresolved importIt is not compiled — plain Node syntax only
output: "standalone" plus a custom serverYour file is absent from the traced outputPick one; standalone emits its own server.js
express.json() before a signature checkHMAC mismatch on valid webhooksRead the raw body: await req.text()
Streaming private files through the appMemory and timeout limits under loadAuthorise, then redirect to a signed URL
Config read with ! in per-request codeOne unset variable takes the whole site downBail out early when it is missing
Custom server for "we need Node APIs"Lost static optimization for a one-line needexport const runtime = "nodejs" in the handler

Frequently asked questions

Do I need a custom server to use Express middleware with Next.js? Usually no. Per-request logic belongs in proxy.ts, and per-endpoint logic in a route handler. Keeping an existing Express backend and calling it from Next is explicitly not a custom server.

Does a custom server break static generation? It removes Automatic Static Optimization, per the Next docs. Pages you generated at build time stop being served as the fast path — here that would be 850 routes.

Can I use a custom server with output: "standalone"? No. The docs state that standalone mode does not trace custom server files and emits its own minimal server.js; the two cannot be used together.

How do I run WebSockets without ejecting? Either accept the custom server for that one process, or run the socket service separately and let the Next app talk to it. The second keeps your pages static and is what most deployments end up doing.

Templates that ship without an ejected server

Every file quoted above is in a repository whose scripts are still next dev and next start. That is the point: the storefront's headers, session refresh, signed webhook and gated downloads all live in config and handlers, so 850 routes stay static and the deploy stays boring. The landing-page templates below start from the same arrangement.

Keep reading

Tutorial12 min read

Data Tables in Next.js: Virtualizing 10,000 Rows Without Losing the Server

Virtualization forces the table into a Client Component and hands back the sorting, filtering and accessibility the server did for free. The three options, and the hybrid worth using.

Read more
Tutorial8 min read

Next.js Deployment: The Env-Var Mistake That Breaks Every Page

Static export can't run middleware, a restart isn't a rebuild, and a missing env var in a proxy file breaks the whole site on first request. Our own go-live checklist.

Read more