Next.js — custom server

Advanced, self-hosted, Node ≥ 26: run Next.js itself inside worker threads. The main thread becomes a TCP acceptor; each worker boots its own next() instance and serves it through serveHttp. Renders, route handlers, and JSON serialization all happen off the API thread.

Read this first

This is a deployment topology, not a supported Next.js integration — the example repo doesn't ship it, it requires standalone output and a custom server (which Next.js documents as opt-out of some optimizations), and it gives up Vercel-style serverless entirely. Documented here because createHttpCluster + serveHttp make it mechanically possible: the housed handler is just (req, res) — the same shape as the NestJS housed app.

Topology

server.mjsbackend
// server.mjs — self-hosted Next with a worker-thread front door (Node ≥ 26)
// Topology sketch: the clustered listener hands each TCP connection to a
// worker unparsed; every worker runs its own next() app instance and
// serves it through serveHttp.
import { createHttpCluster } from '@atolljs/node/http';
import { createNodePool } from '@atolljs/node';
import { Worker } from 'node:worker_threads';

const pool = createNodePool({
  worker: () => new Worker(new URL('./app.worker.mjs', import.meta.url)),
  poolSize: 'auto',
});
createHttpCluster({ pool, port: 3001, route: stickyByAddress() });
app.worker.mjsbackend
// app.worker.mjs — each worker boots its own Next instance and houses it
import '@atolljs/node/shim';
import next from 'next';
import { serveHttp } from '@atolljs/node/http';

const app = next({ dev: false });
await app.prepare();
// serveHttp accepts transferred sockets AND any internal gateway port —
// every request render/parse/serialize happens off the API thread.
serveHttp(app.getRequestHandler());

Socket transfer is per-connection and unparsed — the acceptor can't route by URL path. If you need per-path routing (e.g. /api/* to workers, pages to main), keep Next on the main thread and put a parsing gateway in front; see Node.js → Gateway routing for the trade-off.

WebSockets

websocket placementbackend
// WebSockets under a custom server — two placements:
//
// A) Upgrade proxy: main thread keeps the HTTP listener and forwards each
//    'upgrade' event into a worker. Fast to adopt; one proxy hop per frame.
//    httpServer.on('upgrade', (req, socket, head) =>
//      proxyUpgradeToWorker(req, socket, head));
//
// B) ws server inside the worker attached to serveHttp's listener — frames
//    never cross threads at all. This is how examples/nestjs runs WS; the
//    same serveHttp surface exists under createHttpCluster.
//
// Affinity: a transferred socket pins to one worker for life, but separate
// connections round-robin — multi-connection flows (socket.io's
// polling→upgrade) need createHttpCluster({ route: stickyByAddress() }) so
// the client address hashes onto one worker.

Route-handler WebSocket endpoints don't exist in App Router — ws requires a custom server regardless of atoll. Once you have one, the worker-side placement keeps frame encode/decode and fan-out off the API thread. Full semantics: Node.js → WebSockets.

When it's worth it

Long-lived self-hosted deployments where the Node event loop is the bottleneck — heavy RSC serialization, high connection counts, or realtime fan-out sharing memory with a read-model pool. For most apps the simpler wins are upstream: a pooled task API plus direct shared-memory reads cover the common cases without a custom server.