Skip to content

A custom server.ts in front of Next.js: when next start is not enough

A custom server.ts in front of Next.js: when next start is not enough

next start gives you HTTP and that is it. The moment a WebSocket joins the picture, the process model changes — and Next stops shipping the answer for you.

The repo I am pulling from is a Next.js 15.2.4 app with a single file at the root called server.ts. It is 35 lines long. Those 35 lines are the reason package.json still says "start": "next start" but production does not actually run that script — production runs the compiled output of server.ts. This post is about why that line exists, what it costs, and the specific signal that should make a senior engineer reach for a custom server instead of staying on the well-paved Next path.

The decision and why it cannot be deferred

A custom server in front of Next is the kind of decision that looks reversible and is not. Once server.ts sits at the root of the project, every deploy target you pick has to accept a Node process that owns its own HTTP listener. Vercel's edge-optimised next start path is gone. The standalone output target needs reconfiguration. The Dockerfile changes. The local dev script changes. So you do not want to add a custom server "in case we need WebSockets later" — you want to add it when something in the request lifecycle cannot be expressed as a Next API route, and you want to be honest about which signal you saw.

In this repo the signal is the HTTP upgrade frame. A browser opening a WebSocket to /projects/<id> sends an Upgrade: websocket header. Next.js's request handler does not have an opinion about that header. It cannot, because next start boots an internal HTTP server that only forwards normal requests through the App Router pipeline. To respond to upgrade, you need access to the same http.Server instance that owns the listening socket — and Next does not expose it on the next start path.

So the question is not "do I need a custom server", it is "do I have a protocol that lives below the App Router". If the answer is yes, the decision is already made.

Option A — stay on next start and route around the gap

The default option is to leave Next on next start and put the WebSocket somewhere else: a separate Node process, a dedicated WS server on a different port, or a managed service like Pusher / Ably / a Redis pub-sub bridge.

What you get: deploy parity. Every Next-friendly host still works. next start is supported by Vercel, by output: 'standalone', by every PaaS that knows how to run a Next app. Logs, metrics, and dashboards all assume that shape. The dev experience stays next dev and a separate npm run ws. You can ship a feature behind a feature flag and pull it without touching the boot path.

What you pay: two processes, two sets of secrets, two scaling stories. Auth has to be repeated — the WS server needs its own way to read the session cookie, because it is no longer inside the Next pipeline. The frontend now has two URLs to know about: the Next origin and the WS endpoint. In production behind a reverse proxy that gets messy fast, especially if the WS endpoint shares the origin to dodge browser CORS rules.

I have shipped this pattern. It is fine. It is the right call when the WebSocket workload is heavy enough to warrant a separate scaling axis, or when the WS process needs to outlive a Next deploy (long-lived rooms, multiplayer state).

Option B — own the http server, mount Next as a request handler

Option B is what this repo does. server.ts constructs the HTTP server itself and hands normal requests to Next's request handler, but it also registers an upgrade listener on the same server. The upgrade listener is where the WebSocket service attaches.

Here is the full file:

// server.ts
import { createServer } from 'http'
import { parse } from 'url'
import next from 'next'
import ProjectWSService from './services/ProjectWSService'

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 server = createServer((req, res) => {
    const parsedUrl = parse(req.url || '', true);
    handle(req, res, parsedUrl);
  });

  server.on('upgrade', (req, socket, head) => {
    const { pathname } = parse(req.url || '', true);

    if (pathname && pathname.startsWith('/projects/')) {
      ProjectWSService.getInstance().initialize(server);
    }
  });

  server.listen(port, () => {
    console.log(`> Ready on http://localhost:${port}`);
  });
});

What you get: one process, one origin, one auth context. The same Node process holds the App Router pipeline and the upgrade handler. The browser opens wss://example.com/projects/abc and there is no CORS wall, no separate domain to whitelist, no second deployment to keep in lockstep.

What you pay: you have left the Next deploy story. npm run start in package.json still says next start for muscle memory, but it is now a lie — production does not run that script. The Dockerfile in this repo uses output: 'standalone' artefacts and an entrypoint that boots the custom server, not next start. Vercel does not host this shape. Every contributor needs to know that the dev server is not next dev either — it is nodemon against server.ts.

Option C — split the proxy at the edge

There is a third option that comes up in interviews more than in production: terminate WebSockets at the edge (Cloudflare Workers, Fly.io machines, a Caddy / nginx upstream split) and let next start handle the HTTP traffic. The edge does protocol-level routing — anything with Upgrade: websocket and a path matching /projects/* goes to a Worker or a dedicated upstream; everything else goes to Next.

It works. It is the right call when you already have an edge tier doing TLS termination and you want to keep Next on its standard rails. The cost is operational complexity: now the edge config is part of the application, not just infrastructure, because the routing rule that decides "this is a WS upgrade" is functionally code. Move that file wrong and the feature breaks in production while every Next route still answers 200.

For a team that does not already operate at the edge, Option C imports a whole new layer to dodge 35 lines of Node. The math rarely works out.

The deciding factor in this repo and the artifact that justifies it

The deciding factor is the path filter inside the upgrade handler:

server.on('upgrade', (req, socket, head) => {
  const { pathname } = parse(req.url || '', true);

  if (pathname && pathname.startsWith('/projects/')) {
    ProjectWSService.getInstance().initialize(server);
  }
});

That if (pathname.startsWith('/projects/')) is the load-bearing line. It is the reason the custom server is justified in this codebase. There is a specific URL space — projects — that needs bidirectional state sync, not just request/response. Look at ProjectWSService.ts and the shape becomes obvious:

// services/ProjectWSService.ts
public initialize(server: any): void {
  this.wss = new WebSocketServer({ server });

  this.wss.on('connection', (ws, req) => {
    const path = req.url || '/';
    if (path.startsWith('/_next')) {
      return;
    }
    this.handleConnection(ws, path);
  });
}

new WebSocketServer({ server }) is from the ws package (version ^8.18.2 in package.json). That option attaches the WS server to an existing HTTP server's upgrade event. There is no way to do that from inside a Next API route — Next does not give you the http.Server. You can intercept request and response, but the upgrade frame is consumed at the listener level, before any router gets a chance to look at it.

The handler downstream confirms the use case is not toy:

// services/ProjectWSService.ts (continued)
private async handleConnection(ws: WebSocket, path: string): Promise<void> {
  const match = path.match(/^\/projects\/([a-zA-Z0-9-]+)$/);
  if (!match) {
    ws.send(JSON.stringify({ error: 'Invalid path' }));
    ws.close(1008, 'Invalid path');
    return;
  }

  const projectId = match[1];
  const project = await prisma.project.findUnique({
    where: { projectId: projectId },
  });

  if (!project) {
    ws.send(JSON.stringify({ error: 'Project not found' }));
    ws.close(1008, 'Project not found');
    return;
  }

  ws.send(JSON.stringify({
    type: 'project:init',
    payload: {
      id: project.projectId,
      name: project.name,
      scene: project.scene,
      description: project.description,
    },
  }));

  ws.on('message', async (message: string) => {
    const data = JSON.parse(message.toString());
    if (data.type === 'project:save') {
      await prisma.project.update({
        where: { projectId: projectId },
        data: data.payload,
      });
    }
  });
}

The WS connection loads a project by id, streams its scene payload back to the client, and then accepts project:save messages that write straight into Prisma. That is not "live updates" pretending to need WebSockets. It is a long-lived editor session with two-way state — exactly the protocol that does not fit a request/response API.

So the deciding factor here is structural, not aspirational. The product already shipped multi-layer editing (see commits ba6e0b8 "multi layer analysis" and 14e48c9 "multi layer analysis colors"). The shape of those features pushes against the HTTP-only model. Once you have that shape, Option A's two-process cost and Option C's edge-tier cost both look worse than the 35 lines.

What the deploy story actually looks like

The trade-off shows up in the boot path. Two configs change visibly. First, nodemon.json tells the dev server how to run:

{
  "watch": ["server.ts", "services"],
  "ext": "js ts",
  "exec": "ts-node -r tsconfig-paths/register --project tsconfig.server.json server.ts"
}

So local dev is nodemon against server.ts, not next dev. The tsconfig-paths/register is there because the server file uses @/... imports that the runtime would not resolve without it. Worth flagging — this is one of those settings that breaks silently and looks like a typo until you read the registration line.

Second, tsconfig.server.json exists alongside the App Router's tsconfig.json specifically to compile the server file with CommonJS module output and a separate outDir:

{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "module": "commonjs",
    "target": "es2019",
    "lib": ["es2019"],
    "outDir": "dist",
    "isolatedModules": false,
    "noEmit": false,
    "baseUrl": ".",
    "paths": {
      "@/*": ["./*"]
    }
  },
  "include": ["server.ts", "services/**/*.ts"]
}

Two TypeScript configs in one repo is one of those costs that does not show up in any RFC but shows up the first time someone tries to import a .ts file from services/ into a Next API route and the path alias resolves differently. It is the tax for owning the boot file.

The third visible cost is the Dockerfile. The image is built from the Next standalone output, but the entrypoint has to know that server.ts (compiled) is the binary it runs. next start is no longer the runtime — the runtime is whatever the entrypoint shell script points at. Lose track of that and a junior on the team will spend half a day wondering why npm run start in production "doesn't pick up changes" — because nothing in production runs npm run start at all.

Trade-off

The honest trade-off is this. Option B trades deploy portability for protocol completeness. You buy the ability to speak any TCP-level protocol your http.Server knows about — WebSockets today, raw TCP upgrades later if you ever need them — and you pay it in lost compatibility with the standard Next deploy story. If your hosting target is anything other than a generic Node container, that cost is real. Vercel won't run this. next start won't run this. Any team member who reaches for npm start instead of the entrypoint will be confused.

The trade-off has a second, quieter cost: every Next minor that ships a new boot-path optimisation (a new caching layer, a new RSC streaming improvement) has to be vetted against server.ts doing the right thing with the request handler. You are now downstream of Next's getRequestHandler() API surface, and that API is undocumented enough that a 0.x version change can change its behaviour without you noticing in CI.

There is no version of this where you keep the WebSocket and keep the Next-blessed deploy path. Pick one.

Business impact

For the team operating this repo, the impact is that geospatial editing — the actual product — gets a clean architecture. One origin, one auth cookie, one Node process. The browser does not need to know that opening /projects/abc for HTTP gets a React page while upgrading the same URL to ws: gets a Prisma-backed scene editor. That is the product, and the product reads cleanly in the codebase because the boot file makes it possible.

For a CTO weighing the same call on a different product, the business question is narrower than the engineering one. If the protocol need is real — bidirectional state, long-lived sessions, server push that polling cannot fake — Option B is cheap to ship and cheap to maintain at the scale of one Node container. If the protocol need is aspirational ("we might add live cursors later"), Option A keeps your options open and your deploy story intact. The damage is in the middle case: shipping Option B because it sounded fun, then writing a deploy story for it because Vercel cannot host it.

What to do next

A practical check, not a CTA: open server.ts in your own project — or the equivalent boot file — and read the upgrade handler. If there isn't one, ask why. If there is, ask which path prefix it guards and what would break in production if that prefix changed. If you cannot answer in one sentence, the custom server is not earning its keep yet. That is the gate. Cross it deliberately, or stay on next start and put the protocol-heavy parts on a separate process where you can scale and reason about them in isolation.


{
  "title": "Custom server.ts vs next start: when to leave the path",
  "metaDescription": "A senior engineer's take on putting a custom server.ts in front of Next.js — the WebSocket upgrade signal, the deploy cost, and when Option A is enough.",
  "slug": "custom-next-server-ts",
  "primaryKeyword": "custom next.js server",
  "secondaryKeywords": ["next.js websocket", "next start vs custom server", "next.js http upgrade", "server.ts next.js", "next.js ws integration"],
  "searchIntent": "informational",
  "audience": "CTOs, senior engineers, fellow practitioners",
  "schema": {
    "type": "BlogPosting",
    "faq": [
      {
        "q": "When is next start enough?",
        "a": "When every protocol your app needs fits the HTTP request/response model. As soon as you need to handle an Upgrade frame — WebSockets, raw TCP — next start does not give you the http.Server to attach to, and a custom server.ts becomes the cleanest option."
      },
      {
        "q": "Can I add WebSockets without a custom server?",
        "a": "Yes — run a second Node process on a different port (Option A) or terminate WS at an edge tier (Option C). Both keep next start intact at the cost of an extra process or an extra deployment layer."
      },
      {
        "q": "What breaks when I add server.ts?",
        "a": "Vercel hosting, the next start script in package.json (it becomes vestigial), and the assumption that one tsconfig.json covers the whole repo. You will likely add a tsconfig.server.json and a custom Dockerfile entrypoint."
      }
    ]
  }
}

Related Articles

Same Category

Comments (0)

Newsletter

Stay updated! Get all the latest and greatest posts delivered straight to your inbox