Skip to main content
The Node.js runtime powers server-side rendering, API routes, and server components in your Next.js application. The adapter transforms these into optimized serverless functions for Vercel’s infrastructure.

What runs on Node.js runtime

The following Next.js outputs use Node.js runtime:
  • Pages Router: Server-side rendered pages and API routes
  • App Router: Server components, route handlers, and server actions
  • Server functions: Any function with export const runtime = 'nodejs'
Node.js runtime is identified by output.runtime === 'nodejs' in the adapter (index.ts:106-108).

Node.js function structure

Node.js functions use the standard Node.js HTTP interface:
type NodeHandler = (
  req: IncomingMessage,
  res: ServerResponse,
  internalMetadata: any
) => Promise<void>
This is implemented in the launcher file at node-handler.ts:370-439.

Build process

The adapter creates Node.js functions with a deterministic structure (outputs.ts:269-435):

1. Function directory creation

Each route gets its own function directory (outputs.ts:328-332):
const functionDir = path.join(
  functionsDir,
  `${normalizeIndexPathname(output.pathname, config)}.func`
);
await fs.mkdir(functionDir, { recursive: true });
The normalizeIndexPathname function ensures consistent naming:
  • / becomes /index
  • /basePath becomes /basePath/index

2. Asset bundling

The adapter collects all required assets relative to the repo root (outputs.ts:334-361):
const files: Record<string, string> = {};

// Include page assets
for (const [relPath, fsPath] of Object.entries(output.assets)) {
  files[relPath] = path.posix.relative(repoRoot, fsPath);
}
files[path.posix.relative(repoRoot, output.filePath)] =
  path.posix.relative(repoRoot, output.filePath);

// For Pages Router, include 404 handler for not-found rendering
if (output.type === AdapterOutputType.PAGES) {
  const notFoundOutput = pages404Output || pagesErrorOutput;
  if (notFoundOutput) {
    for (const [relPath, fsPath] of Object.entries(notFoundOutput.assets)) {
      files[relPath] = path.posix.relative(repoRoot, fsPath);
    }
  }
}
The adapter uses relative paths from the repo root to enable function deduplication across monorepos.

3. Launcher generation

The launcher file (___next_launcher.cjs) is generated with embedded logic for routing and rendering (node-handler.ts:9-468):
process.env.NODE_ENV = 'production';
process.chdir(__dirname);

require('next/setup-node-env')

const _n_handler = (/* handler function */)();

module.exports = _n_handler;
module.exports.getRequestHandlerWithMetadata = (metadata) => {
  return (req, res) => _n_handler(req, res, metadata);
};
The launcher file cannot use imports outside the function since it needs to be stringified entirely.

Handler implementation

The Node.js handler performs several critical functions:

Route matching

The handler matches incoming URLs to Next.js pages (node-handler.ts:184-256):
function matchUrlToPage(urlPathname: string): {
  matchedPathname: string;
  locale?: string;
  matches?: RegExpMatchArray | null;
} {
  // 1. Normalize _next/data URLs
  urlPathname = normalizeDataPath(urlPathname);
  
  // 2. Strip RSC and segment prefetch suffixes
  for (const suffixRegex of [
    /\.segments(\/.*)\. segment\.rsc$/,
    /\.rsc$/
  ]) {
    urlPathname = urlPathname.replace(suffixRegex, '');
  }
  
  // 3. Extract locale
  const normalizeResult = normalizeLocalePath(
    urlPathname,
    i18n?.locales
  );
  urlPathname = normalizeResult.pathname;
  
  // 4. Match against routes
  const combinedRoutes = [...staticRoutes, ...dynamicRoutes];
  
  // Try literal match first
  for (const route of combinedRoutes) {
    if (route.page === urlPathname) {
      return {
        matchedPathname: inversedAppRoutesManifest[route.page] || route.page,
        locale: normalizeResult.locale
      };
    }
  }
  
  // Try regex match with fallback:false handling
  for (const route of combinedRoutes) {
    const matches = urlPathname.match(route.namedRegex);
    if (matches) {
      const fallbackFalseMap = prerenderFallbackFalseMap[route.page];
      if (fallbackFalseMap && !fallbackFalseMap.includes(urlPathname)) {
        continue; // Skip this route
      }
      return {
        matchedPathname: inversedAppRoutesManifest[route.page] || route.page,
        locale: normalizeResult.locale,
        matches
      };
    }
  }
}

Data URL normalization

Pages Router _next/data URLs are normalized (node-handler.ts:170-182):
function normalizeDataPath(pathname: string) {
  if (!(pathname || '/').startsWith('/_next/data')) {
    return pathname;
  }
  // /_next/data/BUILD_ID/page.json -> /page
  pathname = pathname
    .replace(/\/_next\/data\/[^/]{1,}/, '')
    .replace(/\.json$/, '');
    
  if (pathname === '/index') {
    return '/';
  }
  return pathname;
}

Locale detection

For i18n applications, the handler detects and strips locale prefixes (node-handler.ts:134-168):
function normalizeLocalePath(
  pathname: string,
  locales?: readonly string[]
): { pathname: string; locale?: string } {
  if (!locales) return { pathname };
  
  const lowercasedLocales = locales.map(locale => locale.toLowerCase());
  const segments = pathname.split('/', 2);
  
  if (!segments[1]) return { pathname };
  
  const segment = segments[1].toLowerCase();
  const index = lowercasedLocales.indexOf(segment);
  
  if (index < 0) return { pathname };
  
  const detectedLocale = locales[index];
  pathname = pathname.slice(detectedLocale.length + 1) || '/';
  
  return { pathname, locale: detectedLocale };
}

Module loading

The handler dynamically loads the appropriate Next.js page module (node-handler.ts:409-417):
const { matchedPathname: page, locale, matches } = matchUrlToPage(urlPathname);
const isAppDir = page.match(/\/(page|route)$/);

const mod = await require(
  './' + path.posix.join(
    relativeDistDir,
    'server',
    isAppDir ? 'app' : 'pages',
    `${page === '/' ? 'index' : page}.js`
  )
);

await mod.handler(req, res, {
  waitUntil: getRequestContext().waitUntil,
  requestMeta: {
    minimalMode: true,
    relativeProjectDir: '.',
    locale,
    initURL
  }
});

App path normalization

App Router paths with route groups are normalized (node-handler.ts:110-132):
const appPathRoutesManifest = require(
  './app-path-routes-manifest.json'
) as Record<string, string>;

// Maps /hello/(foo)/page -> /hello
const inversedAppRoutesManifest = Object.entries(
  appPathRoutesManifest
).reduce(
  (manifest, [originalKey, normalizedKey]) => {
    manifest[normalizedKey] = originalKey;
    return manifest;
  },
  {} as Record<string, string>
);

Request context

Node.js functions access Vercel’s request context for advanced features (node-handler.ts:258-270):
const SYMBOL_FOR_REQ_CONTEXT = Symbol.for('@vercel/request-context');

function getRequestContext(): Context {
  const fromSymbol: typeof globalThis & {
    [SYMBOL_FOR_REQ_CONTEXT]?: { get?: () => Context };
  } = globalThis;
  return fromSymbol[SYMBOL_FOR_REQ_CONTEXT]?.get?.() ?? {};
}
This provides:
  • waitUntil: Extend function execution for background tasks
  • headers: Access to platform-specific headers

Router server context

The handler sets up a global context for server-side operations (node-handler.ts:272-356):
const RouterServerContextSymbol = Symbol.for(
  '@next/router-server-methods'
);

type RouterServerContext = Record<string, {
  render404?: (
    req: IncomingMessage,
    res: ServerResponse
  ) => Promise<void>;
}>;

routerServerGlobal[RouterServerContextSymbol]['.'] = {
  async render404(req, res) {
    // Try loading _not-found, 404, or _error page
    let mod;
    try {
      mod = await require('./server/app/_not-found/page.js');
    } catch {
      try {
        mod = await require('./server/pages/404.js');
      } catch {
        mod = await require('./server/pages/_error.js');
      }
    }
    res.statusCode = 404;
    await mod.handler(req, res, {
      waitUntil: getRequestContext().waitUntil
    });
  }
};
This context allows Next.js to render 404 pages without making network requests.

Function configuration

Each Node.js function has a .vc-config.json with Lambda settings (outputs.ts:405-430):
const nodeConfig: NodeFunctionConfig = {
  filePathMap: files,                    // All required files
  operationType: 'PAGE' | 'API',        // Function type
  framework: {
    slug: 'nextjs',
    version: nextVersion
  },
  handler: '___next_launcher.cjs',      // Entry point
  runtime: 'nodejs20.x',                // Node.js version
  maxDuration: output.config.maxDuration,
  supportsMultiPayloads: true,          // Lambda invoke optimization
  supportsResponseStreaming: true,      // Streaming responses
  experimentalAllowBundling: true,      // Allow esbuild bundling
  useWebApi: false,                     // Use Node.js HTTP interface
  launcherType: 'Nodejs'
};

Lambda optimizations

Allows the Lambda to handle multiple invocations within a single execution context, reducing cold starts.
supportsMultiPayloads: true
Enables streaming responses for better Time-To-First-Byte (TTFB) with React Server Components.
supportsResponseStreaming: true
Allows Vercel to bundle the function with esbuild for smaller package sizes.
experimentalAllowBundling: true

Deterministic functions

To enable deduplication, the adapter creates a deterministic routes manifest (outputs.ts:251-266):
async function writeDeterministicRoutesManifest(distDir: string) {
  const manifest: RoutesManifest = require(
    path.join(distDir, 'routes-manifest.json')
  );
  
  // Remove non-deterministic fields
  manifest.headers = [];
  manifest.onMatchHeaders = [];
  delete manifest.deploymentId;
  
  const outputManifestPath = path.join(
    distDir,
    'routes-manifest-deterministic.json'
  );
  await fs.writeFile(outputManifestPath, JSON.stringify(manifest));
  return outputManifestPath;
}
This allows multiple routes that use the same page to share a single Lambda function.

Prerender functions

Prerendered pages get linked to their parent function using symlinks (outputs.ts:508-534):
const parentFunctionDir = path.join(
  functionsDir,
  `${normalizeIndexPathname(parentNodeOutput.pathname, config)}.func`
);

const prerenderFunctionDir = path.join(
  functionsDir,
  `${normalizeIndexPathname(output.pathname, config)}.func`
);

if (output.pathname !== parentNodeOutput.pathname) {
  await fs.symlink(
    path.relative(
      path.dirname(prerenderFunctionDir),
      parentFunctionDir
    ),
    prerenderFunctionDir
  );
}
Each prerender also gets a .prerender-config.json (outputs.ts:583-628):
{
  group: output.groupId,                // Revalidation group
  expiration: output.fallback?.initialRevalidate || 1,
  staleExpiration: output.fallback?.initialExpiration,
  sourcePath: parentNodeOutput?.pathname,
  passQuery: true,                      // Send query as params
  allowQuery: output.config.allowQuery, // Allowed query keys
  allowHeader: output.config.allowHeader,
  bypassToken: output.config.bypassToken,
  experimentalBypassFor: output.config.bypassFor,
  initialHeaders: { vary: varyHeader, ...output.fallback?.initialHeaders },
  initialStatus: output.fallback?.initialStatus,
  fallback: 'path/to/fallback.html',    // Static fallback
  chain: output.pprChain                // PPR chain config
}

Middleware on Node.js

Middleware can also run on Node.js runtime (outputs.ts:801-805):
if (output.runtime === 'nodejs') {
  await handleNodeOutputs([output], {
    ...ctx,
    isMiddleware: true
  });
}
The launcher detects middleware mode and loads the middleware module (node-handler.ts:24-61):
if (ctx.isMiddleware) {
  return async function handler(request: Request): Promise<Response> {
    let middlewareHandler = await require(
      './' + path.posix.join(relativeDistDir, 'server', 'middleware.js')
    );
    middlewareHandler = middlewareHandler.handler || middlewareHandler;
    
    const context = getRequestContext();
    const response = await middlewareHandler(request, {
      waitUntil: context.waitUntil,
      requestMeta: { relativeProjectDir: '.' }
    });
    return response;
  };
}
Even though it runs on Node.js, middleware uses the Request/Response Web API instead of IncomingMessage/ServerResponse.

Node.js version

The adapter automatically detects the appropriate Node.js version (outputs.ts:291):
const nodeVersion = await getNodeVersion(
  projectDir,
  undefined,
  {},
  {}
);

// Returns something like:
{
  runtime: 'nodejs20.x',
  discontinueDate: new Date('2026-04-30')
}
This respects:
  • .nvmrc or .node-version files
  • engines.node in package.json
  • Default to latest LTS version

Mojibake handling

The handler includes special handling for character encoding issues in headers (node-handler.ts:358-368):
function fixMojibake(input: string): string {
  // Convert each character's char code to a byte
  const bytes = new Uint8Array(input.length);
  for (let i = 0; i < input.length; i++) {
    bytes[i] = input.charCodeAt(i);
  }
  
  // Decode the bytes as proper UTF-8
  const decoder = new TextDecoder('utf-8');
  return decoder.decode(bytes);
}

let urlPathname = typeof req.headers['x-matched-path'] === 'string'
  ? fixMojibake(req.headers['x-matched-path'])
  : undefined;
This fixes UTF-8 encoding issues that can occur when headers pass through multiple proxies.

Error handling

The handler includes comprehensive error handling (node-handler.ts:432-438):
try {
  // ... handler logic
} catch (error) {
  console.error(`Failed to handle ${req.url}`, error);
  // Re-throw to allow global handler to decide how to handle
  throw error;
}
This allows Vercel’s infrastructure to:
  • Capture errors for monitoring
  • Retry transient failures
  • Serve custom error pages

Build docs developers (and LLMs) love