Custom Server

Next.js and NestJS share one HTTP server on a single port

Replace the default Next.js dev server with a custom Node.js entry point that boots both Next.js and NestJS in the same process. Requests to /api/* are dispatched to NestJS; everything else goes to Next.js. One process, one port, no proxy.

Next.js + NestJSsingle process · port 3000

Setup

Give apps/api a workspace package name so it can be imported from apps/web:

apps/api/package.json
{
  "name": "@my-app/api",
  "exports": {
    ".": "./src/index.ts"
  }
}
apps/api/src/index.ts
export { AppModule } from './app.module';

Add the dependency and tsx to apps/web:

apps/web/package.json
{
  "dependencies": {
    "@my-app/api": "workspace:*"
  },
  "devDependencies": {
    "tsx": "^4.19.4"
  },
  "scripts": {
    "dev": "tsx watch --tsconfig tsconfig.server.json server.ts"
  }
}

server.ts

apps/web/server.ts
import 'reflect-metadata';
import dotenv from 'dotenv';
import { resolve } from 'path';
 
dotenv.config({ path: resolve(process.cwd(), '../../.env') });
dotenv.config({ path: resolve(process.cwd(), '.env') });
 
import { NestFactory } from '@nestjs/core';
import { AppModule } from '@my-app/api';
import next from 'next';
import { createServer } from 'node:http';
 
const dev = process.env.NODE_ENV !== 'production';
const port = parseInt(process.env.PORT ?? '3000', 10);
 
async function main() {
  const nextApp = next({ dev, port });
  const handle = nextApp.getRequestHandler();
 
  await nextApp.prepare();
 
  const nestApp = await NestFactory.create(AppModule);
  await nestApp.init();
 
  const nestListener = nestApp
    .getHttpServer()
    .listeners('request')[0] as (req: any, res: any) => void;
 
  const server = createServer((req, res) => {
    if (req.url?.startsWith('/api/')) {
      nestListener(req, res);
    } else {
      handle(req, res);
    }
  });
 
  server.listen(port, () => {
    console.log(`> Server ready on http://localhost:${port}`);
  });
}
 
main().catch(console.error);

tsconfig.server.json

NestJS decorators require emitDecoratorMetadata. Add a separate tsconfig for the server entry point so it doesn't affect the Next.js build:

apps/web/tsconfig.server.json
{
  "compilerOptions": {
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "target": "ES2023",
    "esModuleInterop": true,
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "allowSyntheticDefaultImports": true,
    "skipLibCheck": true,
    "strictNullChecks": true
  },
  "include": ["server.ts"],
  "exclude": ["node_modules"]
}

next.config.js

Remove the rewrite, since there is no separate API server to proxy to:

apps/web/next.config.js
const nextConfig = {
  output: "standalone",
};
 
export default nextConfig;

Running

# Dev - tsx watches server.ts and restarts on changes
pnpm --filter=web dev
 
# Production
pnpm --filter=web build
node apps/web/server.js

Tradeoffs

Single port: no reverse proxy, no rewrite config, no CORS concerns. The same port handles both the frontend and the API.

Custom server limitations: Next.js documents several features that don't work with a custom server, including automatic static optimization for some page types. For most apps this won't matter, but check the Next.js docs if you rely on specific rendering behaviors.

Startup: NestJS and Next.js both initialize before the server begins accepting requests, so there's no window where the API is unavailable during startup.