
Next.js Setting up a custom server
Every Next.js app already has a server. When you run next start, Next.js spins up an HTTP server for you, wires up its router, and handles every request that comes in. Most of the time you never think about it — it's just there, doing its job. But every so often, you hit a wall that the built-in server can't get you over: maybe you need to bolt a WebSocket server onto the same port, maybe you're migrating a legacy Express app one route at a time, or maybe you need request-handling logic that has to run before Next.js even sees the request, in a way proxy.js can't express.
That's what a custom server is for. You write your own Node.js HTTP server, hand off requests to Next.js's internal request handler when you want Next.js to take over, and keep full control everywhere else. It sounds appealing, and it's a completely legitimate escape hatch, but it comes at a real cost that isn't obvious until you've already committed to it. This article walks through how to build one properly, what you give up the moment you do, and why — in the overwhelming majority of cases — you should exhaust every other option first.
What a Custom Server Actually Is
It's worth being precise about the term, because "custom server" gets used loosely. If you already have a backend — an Express API, a Rails app, whatever — and you're calling it from your Next.js app over HTTP, that's not a custom server. That's just two separate services talking to each other, and it's the normal, fully-supported way most production Next.js apps work.
A custom server, in the specific sense the Next.js docs mean, is when you own the process that Next.js runs inside. Instead of next start booting an HTTP server for you, you write a small Node.js script that creates its own http.Server (or wraps Express, Fastify, Koa, whatever you like), and inside that server's request handler, you explicitly ask Next.js to handle the request. Next.js becomes a library you call into, rather than the process that owns the entrypoint.
Here's the minimal version, straight out of the box:
// server.ts
import { createServer } from "http";
import next from "next";
const port = parseInt(process.env.PORT || "3000", 10);
const dev = process.env.NODE_ENV !== "production";
const app = next({ dev });
const handle = app.getRequestHandler();
app.prepare().then(() => {
createServer((req, res) => {
handle(req, res);
}).listen(port);
console.log(
`> Server listening at http://localhost:${port} as ${
dev ? "development" : process.env.NODE_ENV
}`,
);
});
Three moving parts here matter:
next({ dev }) creates the Next.js application instance. The dev flag controls whether it runs in development mode (on-demand compilation, Fast Refresh, verbose errors) or production mode (serving the pre-built .next output). You almost always derive this from NODE_ENV rather than hardcoding it, since the same server.js file typically runs in both environments.
app.prepare() is an async setup step. It compiles routes in dev mode and loads the build manifest in production mode. This has to resolve before you start accepting traffic — if you call handle() before prepare() finishes, you'll get inconsistent, occasionally broken behavior, which is why everything downstream of it lives inside the .then() callback.
app.getRequestHandler() returns the actual function that does the routing, rendering, and response-writing that Next.js normally does invisibly. You call it with the raw Node.js req and res objects for every request you want Next.js to own.
Update your package.json scripts to point at this file instead of the built-in CLI commands:
{
"scripts": {
"dev": "node server.js",
"build": "next build",
"start": "NODE_ENV=production node server.js"
}
}
Note that build stays exactly the same — you're still using next build to produce the .next output. Only the process that serves that output changes.
If you're on TypeScript, you'll need something that can execute .ts directly in dev, since this file runs outside Next.js's own compiler:
{
"scripts": {
"dev": "tsx server.ts",
"build": "next build",
"start": "NODE_ENV=production tsx server.ts"
}
}
That last point deserves its own callout, because it trips people up.
server.js Doesn't Go Through the Next.js Compiler
This is the single most important thing to understand about custom servers, and it's easy to miss because everything else about working in a Next.js project trains you to expect otherwise. Every file under app/, every API route, every component — all of it passes through Next.js's build pipeline: SWC or Babel transforms, JSX compilation, TypeScript type stripping, all of it automatic. server.js (or server.ts) is different. It's the entrypoint that starts that pipeline, so it can't also be a product of it — Node.js executes it directly.
Practically, that means:
- If you write it in TypeScript, you need
tsx,ts-node, or a manualtsccompile step to run it — Next.js will not transpile it for you. - Whatever syntax you use has to be understood by the Node.js version you're actually running in production. Top-level
await, certain newer ES features, decorators — check compatibility yourself rather than assuming Next.js's usual leniency applies. - Any import inside
server.jsthat pulls in code meant for the App Router (say, a utility fromapp/lib/db.tsthat assumes Next.js's module resolution or path aliases) may not resolve the way you expect, since this file sits outside that resolution context.
I've seen people spend an hour debugging why a @/ path alias works everywhere in their app except inside server.js, only to realize the file isn't part of the Next.js compilation graph in the first place. Keep your custom server file as thin as possible — a request dispatcher, not a place to put business logic — and this mostly stops mattering.
The res.headersSent Trap
This is a subtle bug that the docs call out, and it's worth explaining why it happens rather than just quoting the workaround, because understanding the mechanism is what stops you from reintroducing the bug six months later in a different form.
handle(req, res) doesn't just render a response and hand it back to you — it starts writing directly to res, including calling res.end() internally once Next.js is done. By the time the promise returned by handle() resolves, the HTTP response has almost certainly already been sent to the client. If you try to set a header after that point:
createServer(async (req, res) => {
await handle(req, res);
res.setHeader("Set-Cookie", "sessionId=abc123; Max-Age=2592000"); // too late
});
Node.js will silently drop the setHeader call. Not throw, not warn in most setups — just silently do nothing, because res.headersSent is already true by the time your code runs. This is one of the more maddening classes of bug to debug, because everything looks correct: your code ran, no exception was thrown, the header-setting logic itself is fine in isolation. The problem is purely about when it ran relative to handle().
The fix is to flip the order — do anything that needs to touch the response headers before handing control to Next.js:
createServer(async (req, res) => {
res.setHeader("Set-Cookie", "sessionId=abc123; Max-Age=2592000");
await handle(req, res);
});
This same ordering constraint applies no matter what you're wrapping Next.js in. If you're using Fastify, you're working with reply.raw instead of a bare Node res, but the rule is identical — set headers on the raw response object before delegating to handle(), never after. Same story with Express's res object. The framework changes; the ordering rule doesn't.
If you ever find yourself needing to set response headers based on something Next.js computed during rendering (a value only known deep inside a Server Component, say), a custom server is the wrong tool entirely — that's what headers() inside a Route Handler or Server Component is for, or the headers config in next.config.js for static rules.
Configuration Options for next()
The next() function takes an options object, and it's worth knowing the full surface since most tutorials only ever show { dev }:
| Option | Type | Description |
|---|---|---|
conf | Object | The same shape you'd put in next.config.js. Defaults to {} |
dev | Boolean | Launch in dev mode (on-demand compilation, Fast Refresh). Defaults to false |
dir | String | Location of the Next.js project. Defaults to '.' |
quiet | Boolean | Suppress error messages that contain server information. Defaults to false |
hostname | String | The hostname the server is running behind |
port | Number | The port the server is running behind |
httpServer | node:http#Server | An existing HTTP server instance Next.js should attach to |
turbopack | Boolean | Enable Turbopack (on by default) |
webpack | Boolean | Enable webpack instead |
The hostname and port options are worth calling out specifically: Next.js uses them internally for things like constructing absolute URLs during rendering in certain edge cases, and for correctly binding WebSocket-adjacent behavior in dev mode (Fast Refresh's own dev-time socket). If your custom server binds to a non-default host or port, pass them through explicitly rather than letting Next.js guess — I've seen dev-mode HMR silently fail to connect because the server was listening on 0.0.0.0:4000 but Next.js's internal assumptions defaulted to localhost:3000.
httpServer matters if you're integrating with something that already owns an HTTP server instance — most commonly, a WebSocket library like ws or socket.io that needs to attach its upgrade handler to the same server instance Next.js is using, rather than creating a second, separate server on a different port.
The Real Reason People Reach for This: WebSockets
In practice, the single most common legitimate reason to write a custom server is wanting a persistent WebSocket connection alongside your normal HTTP routes, served from the same origin and port. Here's a realistic shape of that, using ws:
// server.ts
import { createServer } from "http";
import { WebSocketServer } from "ws";
import next from "next";
const port = parseInt(process.env.PORT || "3000", 10);
const dev = process.env.NODE_ENV !== "production";
const app = next({ dev });
const handle = app.getRequestHandler();
app.prepare().then(() => {
const httpServer = createServer((req, res) => {
handle(req, res);
});
const wss = new WebSocketServer({ server: httpServer, path: "/ws" });
wss.on("connection", (socket) => {
socket.on("message", (data) => {
socket.send(`echo: ${data}`);
});
});
httpServer.listen(port, () => {
console.log(`> Ready on http://localhost:${port}`);
});
});
The key detail: the WebSocketServer attaches its upgrade handler to the same httpServer instance Next.js's handle() writes into, listening only on the /ws path. Regular HTTP requests to any other path still flow through to handle(req, res) and get routed by Next.js as normal. This is the pattern that genuinely can't be done any other way in Next.js today — there's no App Router convention for a persistent, bidirectional connection like this, so if you need it, a custom server (or a separate microservice dedicated to WebSockets) is the honest answer.
Note that if your deployment target is serverless (Vercel's default, most edge platforms, AWS Lambda), long-lived WebSocket connections don't really work there regardless of what your server code does — serverless functions are designed to spin up, handle a request, and shut down, which is fundamentally incompatible with a socket that needs to stay open for minutes or hours. If WebSockets are a hard requirement, you're implicitly also choosing a persistent-server deployment target (a VM, a container on a platform like Fly.io or Render, or a dedicated Node.js process on your own infrastructure) rather than a serverless one.
What You Lose the Moment You Add a Custom Server
This is the part that doesn't get emphasized enough, and it's the reason the docs' own framing — "the majority of the time, you will not need this approach" — is doing real work.
Standalone output stops tracing your server file. If you use output: 'standalone' in next.config.js (the recommended mode for Docker deployments, since it produces a minimal server.js with only the dependencies your app actually needs, rather than shipping the entire node_modules), that generated server.js is Next.js's own server — the one you're replacing. The standalone build process does not know about your custom server file and will not trace its dependencies into the minimal output. These two things are mutually exclusive: you either use Next.js's standalone server, or you use your own custom server and take on the responsibility of managing dependencies and the deployment image yourself. There's no middle ground where you get a hand-written custom server and the minimal standalone output.
You take on manual scaling and process management. Next.js's own server, deployed via a platform like Vercel, gets automatic scaling, request isolation, and infrastructure handled for you. A custom server is just a Node.js process — if you need to run more than one instance to handle load, you're now responsible for a process manager (PM2, systemd, Kubernetes, whatever), health checks, graceful shutdown, and load balancing across instances. None of that is hard, exactly, but all of it used to be free, and now it's your job.
Certain platform-level optimizations that depend on Next.js owning the request/response lifecycle become harder to reason about. Things like automatic compression, caching headers derived from your route segment config, and image optimization proxying all still work when routed through handle(), but you've inserted a layer where you control the raw request and response before and after Next.js touches them, so it's now possible to accidentally interfere with headers Next.js set, or double-compress a response, in ways that simply can't happen when Next.js owns the whole request lifecycle itself.
You lose the option to deploy to platforms that only support Next.js's own server contract. Some hosting integrations detect "is this a standard Next.js app" and provision infrastructure accordingly (automatic edge caching rules, automatic route-level function splitting). A custom server, by definition, isn't that anymore from the platform's point of view — it's "a Node.js app that happens to call into Next.js," and some of that automatic detection stops applying.
None of this means "never do it." It means: treat a custom server as a one-way door you should only walk through after confirming, concretely, that nothing else solves your problem.
What to Try First
Before reaching for a custom server, walk through this list — in my experience, it resolves the overwhelming majority of cases people think need one:
"I need to run code before every request." This is what proxy.js (the convention that replaced Middleware) is for. It runs at the edge or in Node.js before a request reaches your routes, and can rewrite, redirect, or short-circuit the request entirely. If your use case is auth gating, A/B test routing, geolocation-based redirects, or header inspection, proxy.js almost certainly covers it without touching the server layer at all.
"I need custom routing logic." The App Router's file-based routing, combined with dynamic segments, route groups, and parallel/intercepting routes, covers a much wider range of "custom" routing needs than people expect going in. Reach for a custom server's manual req.url parsing only after confirming the file-system router genuinely can't express what you need — and in practice, that's rare.
"I need a long-lived connection to push data to the client." Consider whether Server-Sent Events via a Route Handler can do the job instead of a full WebSocket — a ReadableStream response from a route.ts file gives you server-to-client push over plain HTTP, without needing a custom server at all, as long as you don't need the client to send messages back over the same connection.
"I'm migrating an existing Express/Fastify app." Consider running the two as separate services behind a reverse proxy or an API gateway, rather than merging them into one process. It's usually a cleaner migration path, and it means you can migrate incrementally without permanently coupling your Next.js app's deployment model to your legacy server's.
If you've genuinely ruled all of that out — you need a shared-port WebSocket server, or you have some other constraint that truly requires owning the raw HTTP server — then a custom server is the right, supported answer, and the pattern above will serve you well.
Testing a Custom Server Locally
One thing that's easy to get wrong when you first switch to a custom server: dev mode still works basically the same way you're used to, because dev: true still triggers on-demand compilation and Fast Refresh through the standard Next.js dev pipeline — handle() is doing the same work next dev normally does internally, just now explicitly invoked by you. So npm run dev (pointed at node server.js per the script change above) should feel identical to next dev for anything Next.js owns.
Where it diverges is anything outside Next.js's own request handling — your WebSocket server, any custom routes you intercept before calling handle(), any middleware-like logic you've bolted directly onto your HTTP server. None of that gets Fast Refresh, none of it gets automatic restarts on save unless you set that up yourself (typically with nodemon or tsx watch):
{
"scripts": {
"dev": "nodemon --watch server.ts --exec tsx server.ts"
}
}
Without this, you'll edit server.ts, refresh your browser, and wonder why nothing changed — the Node.js process serving your custom server code is still running the old version, even though Next.js's own Fast Refresh is doing its job perfectly for everything else.
Key Takeaways
| Scenario | What to use |
|---|---|
| Need to run logic before every request | proxy.js, not a custom server |
| Need custom URL routing | App Router file-system conventions first |
| Need to push data to the client, one-way | Server-Sent Events via a Route Handler |
| Need a shared-port, bidirectional WebSocket | Custom server, attached to the same httpServer |
| Migrating a legacy Express/Fastify app | Consider running it as a separate service first |
Deploying with output: 'standalone' | Incompatible with a custom server — pick one |
Setting response headers around handle() | Set them before calling handle(), never after |
Writing server.ts | Run it through tsx/ts-node — Next.js won't compile it for you |
A custom server is a legitimate, fully-supported feature, not a hack — but it's also an ejector seat, not a knob you turn casually. The moment you adopt one, you take back a set of responsibilities Next.js was handling invisibly on your behalf: process management, deployment tracing, header ordering, restart-on-save. For the narrow set of problems that genuinely require owning the raw HTTP server — persistent WebSockets chief among them — it's the right tool. For nearly everything else, proxy.js and the App Router's own conventions will get you there with none of the downsides.


