Last active
June 27, 2026 12:59
-
-
Save huksley/9e2d2ea5fa0d4db50392174f8881de61 to your computer and use it in GitHub Desktop.
Modern nextjs custom server which supports both development mode and production standalone builds
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** @type {import('next').NextConfig} */ | |
| const nextConfig = { | |
| reactStrictMode: true, | |
| // Avoid ERR_MODULE_NOT_FOUND by transpiling motion, or framer-motion | |
| transpilePackages: ["motion", "framer-motion"], | |
| experimental: { | |
| scrollRestoration: true, | |
| webVitalsAttribution: ["CLS", "LCP"], | |
| serverSourceMaps: true, | |
| webpackBuildWorker: true, | |
| webpackMemoryOptimizations: true | |
| }, | |
| allowedDevOrigins: process.env.NEXT_PUBLIC_APP_URL | |
| ? [new URL(process.env.NEXT_PUBLIC_APP_URL).hostname] | |
| : undefined, | |
| devIndicators: false, | |
| // https://nextjs.org/docs/app/api-reference/config/next-config-js/logging | |
| logging: { | |
| incomingRequests: false, | |
| fetches: { | |
| fullUrl: true | |
| } | |
| }, | |
| async headers() { | |
| return [ | |
| { | |
| source: "/api/:everything*", | |
| headers: [ | |
| { | |
| key: "Cache-Control", | |
| value: "no-cache, no-store, max-age=0" | |
| }, | |
| { | |
| key: "Access-Control-Max-Age", | |
| value: "86400" | |
| }, | |
| { | |
| key: "Access-Control-Allow-Origin", | |
| value: "*" | |
| }, | |
| { | |
| key: "Access-Control-Allow-Credentials", | |
| value: "true" | |
| }, | |
| { | |
| key: "Access-Control-Allow-Headers", | |
| value: | |
| "content-type,authorization,mcp-session-id,last-event-id,mcp-protocol-version" | |
| }, | |
| { | |
| key: "Access-Control-Expose-Headers", | |
| value: | |
| "content-type,authorization,mcp-session-id,last-event-id,mcp-protocol-version" | |
| } | |
| ] | |
| } | |
| ]; | |
| } | |
| }; | |
| module.exports = nextConfig; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * Custom Next.js server (Pages Router) with structured request logging, | |
| * graceful shutdown, PID-file management and standalone-build support. | |
| * | |
| * It wraps Node's `http` server around Next.js and supports both the regular | |
| * Next server and the `output: "standalone"` build. Each request gets an | |
| * `X-Request-Id`, timing, status and (optionally) client IP logged with colour. | |
| * | |
| * Based on https://nextjs.org/docs/pages/building-your-application/configuring/custom-server | |
| * | |
| * Usage: | |
| * node custom.js | |
| * | |
| * Supported environment variables: | |
| * | |
| * PORT Port to listen on. Default: 3000 | |
| * HOSTNAME Host/interface to bind to. Default: 127.0.0.1 | |
| * NODE_ENV "production" enables prod mode; anything else is dev. | |
| * | |
| * NEXT_PUBLIC_LOG_VERBOSE Set to "1" to log noisy endpoints (/_next, /__nextjs) | |
| * and per-request verbose output. Default: off. | |
| * SERVER_LOG_IP_ADDRESS Set to "1" to resolve and log the client IP from | |
| * x-real-ip / x-forwarded-for (private IPs filtered). | |
| * Default: off. | |
| * | |
| * KEEP_ALIVE_TIMEOUT Keep-alive timeout in ms. Default: 60000 | |
| * SERVER_REQUEST_TIMEOUT Per-request timeout in ms. Default: 600000 | |
| * SERVER_SOCKET_TIMEOUT Per-socket timeout in ms. Default: unset (no timeout). | |
| * SERVER_SOCKET_NODELAY Set to "1" to enable TCP_NODELAY on sockets. | |
| * SERVER_REUSE_PORT Set to "1" to enable SO_REUSEPORT on listen. | |
| * | |
| * SERVER_PID_FILE Path to write the process PID to on startup (and | |
| * remove on shutdown). "{pid}" in the path is replaced | |
| * with the actual PID. Parent dirs are created. | |
| * SERVER_SHUTDOWN_TIMEOUT Graceful shutdown timeout in ms before forcing exit | |
| * on SIGINT/SIGTERM. Default: 60000 | |
| */ | |
| // @ts-check | |
| /* eslint-disable no-console */ | |
| const { install } = require("source-map-support"); | |
| install(); | |
| const { createServer } = require("node:http"); | |
| const { parse } = require("node:url"); | |
| const createNextServer = require("next"); | |
| require("next"); | |
| const { initialize } = require("next/dist/server/lib/router-server"); | |
| const fs = require("node:fs"); | |
| const { customAlphabet } = require("nanoid"); | |
| const path = require("node:path"); | |
| const { Chalk } = require("chalk"); | |
| const chalk = new Chalk(); | |
| /** | |
| * Filter out private IPs (both IPv6 or IPv4) and return possible IP | |
| * address for Source IP and Forwarded for header combination | |
| * | |
| * @param {string | string[] | undefined} sourceIp | |
| * @param {string | string[] | undefined} forwardedFor | |
| * @returns {string | undefined} | |
| **/ | |
| const getRealIp = (sourceIp, forwardedFor) => { | |
| const myIps = [ | |
| ...(sourceIp ? (Array.isArray(sourceIp) ? sourceIp : [sourceIp]) : []), | |
| ...(forwardedFor | |
| ? Array.isArray(forwardedFor) | |
| ? forwardedFor.map(v => v.trim().split(/,/)).flat() | |
| : forwardedFor.split(/,/) | |
| : []) | |
| ] | |
| .filter(v => v !== undefined && v !== null && v.trim() !== "") | |
| .map(ip => ip.trim()) | |
| .filter( | |
| ip => | |
| ip !== "undefined" && | |
| ip != "::1" && | |
| !ip.startsWith("::ffff:10.") && | |
| !ip.startsWith("::ffff:192.168.") && | |
| !ip.startsWith("::ffff:127.0.") | |
| ) | |
| .filter( | |
| ip => !ip.startsWith("127.0") && !ip.startsWith("192.168.") && !ip.startsWith("10.") | |
| ) | |
| .map(ip => (ip.startsWith("::ffff:") ? ip.substring(7) : ip)) | |
| .map(ip => ip.trim()) | |
| .filter(ip => ip != ""); | |
| return myIps.length > 0 ? myIps[0] : undefined; | |
| }; | |
| class Level { | |
| constructor(level = "INFO") { | |
| this.level = level; | |
| } | |
| toString() { | |
| return new Date().toISOString() + " " + this.level; | |
| } | |
| /** | |
| * @param {string} hint | |
| */ | |
| [Symbol.toPrimitive](hint) { | |
| if (hint === "string") { | |
| return new Date().toISOString() + " " + this.level; | |
| } | |
| return undefined; | |
| } | |
| } | |
| const verbose = process.env.NEXT_PUBLIC_LOG_VERBOSE === "1"; | |
| const NOISY_ENDPOINTS = ["/_next", "/__nextjs"]; | |
| const logger = { | |
| verbose: verbose ? console.info : () => {}, | |
| info: console.info.bind(console.info, "%s", new Level("INFO")), | |
| warn: console.warn.bind(console.warn, "%s", new Level("WARN")), | |
| error: console.error.bind(console.error, "%s", new Level("ERROR")) | |
| }; | |
| /** @param {string} s */ | |
| const dimColor = s => (s ? chalk.dim(s) : s); | |
| const hostname = process.env.HOSTNAME ?? "127.0.0.1"; | |
| const port = parseInt(process.env.PORT || "3000", 10); | |
| const dev = process.env.NODE_ENV !== "production"; | |
| const keepAliveTimeout = process.env.KEEP_ALIVE_TIMEOUT | |
| ? parseInt(process.env.KEEP_ALIVE_TIMEOUT, 10) | |
| : 60000; | |
| const logIpAddress = process.env.SERVER_LOG_IP_ADDRESS === "1"; | |
| const gracefulShutdownTimeout = process.env.SERVER_SHUTDOWN_TIMEOUT | |
| ? parseInt(process.env.SERVER_SHUTDOWN_TIMEOUT, 10) | |
| : 60000; | |
| /** @type {import("next/dist/server/next").RequestHandler | undefined} */ | |
| let requestHandler = undefined; | |
| /** @type {import("next/dist/server/next").UpgradeHandler | undefined} */ | |
| let upgradeHandler = undefined; | |
| const pidFileName = process.env.SERVER_PID_FILE | |
| ? process.env.SERVER_PID_FILE.replace("{pid}", String(process.pid)) | |
| : undefined; | |
| if (pidFileName) { | |
| const dir = path.dirname(pidFileName); | |
| if (!fs.existsSync(dir)) { | |
| fs.mkdirSync(dir, { recursive: true }); | |
| } | |
| fs.writeFileSync(pidFileName, String(process.pid)); | |
| } | |
| let shutdown = false; | |
| const niceid = customAlphabet( | |
| "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ", | |
| 16 | |
| ); | |
| const server = createServer( | |
| { | |
| maxHeaderSize: 8192, | |
| insecureHTTPParser: false | |
| }, | |
| /** | |
| * @param {import("http").IncomingMessage & { ip?: string, url?: string }} req | |
| * @param {import("http").ServerResponse<import("http").IncomingMessage>} res | |
| */ | |
| async (req, res) => { | |
| if (process.env.SERVER_SOCKET_NODELAY === "1") { | |
| req.socket.setNoDelay(true); | |
| } | |
| if (process.env.SERVER_SOCKET_TIMEOUT) { | |
| req.socket.setTimeout(parseInt(process.env.SERVER_SOCKET_TIMEOUT, 10)); | |
| } | |
| if (!req.url) { | |
| res.statusCode = 400; | |
| logger.warn("Bad request", req.url); | |
| res.end("Bad request"); | |
| return; | |
| } | |
| const requestId = niceid(); | |
| res.setHeader("X-Request-Id", requestId); | |
| const idPrefix = [dimColor(requestId)]; | |
| const parsedUrl = parse(req.url, true); | |
| const pathname = parsedUrl.pathname; | |
| const start = Date.now(); | |
| const ipAddress = logIpAddress | |
| ? getRealIp( | |
| req?.ip || | |
| req?.headers["x-real-ip"] || | |
| req?.connection?.remoteAddress || | |
| req?.socket?.remoteAddress, | |
| req?.headers["x-forwarded-for"] | |
| ) | |
| : undefined; | |
| const query = Object.entries(parsedUrl.query) | |
| .map(([key, value]) => `${key}=${value}`) | |
| .join("&"); | |
| const noisyEndpoint = !!NOISY_ENDPOINTS.find(endpoint => pathname?.startsWith(endpoint)); | |
| if (!noisyEndpoint || verbose) { | |
| logger.info(...idPrefix, "-->", req.method, pathname, dimColor(query), ipAddress ?? ""); | |
| } | |
| if (!requestHandler) { | |
| logger.warn(...idPrefix, "-->", req.method, pathname, "No NextJS request handler"); | |
| res.statusCode = 500; | |
| res.end("Internal server error"); | |
| return; | |
| } | |
| if (shutdown) { | |
| res.setHeader("Connection", "close"); | |
| } | |
| await requestHandler(req, res, parsedUrl).then(() => { | |
| const elapsed = Date.now() - start; | |
| if (!noisyEndpoint || verbose || elapsed > 100) { | |
| (res.statusCode >= 200 && res.statusCode < 300 ? logger.info : logger.warn)( | |
| ...idPrefix, | |
| res.statusCode > 399 ? chalk.red(res.statusCode) : res.statusCode, | |
| req.method, | |
| pathname, | |
| dimColor(query), | |
| ipAddress ? ipAddress + " Δ" : "Δ", | |
| elapsed, | |
| "ms" | |
| ); | |
| } | |
| }); | |
| } | |
| ); | |
| server.timeout = 120000; | |
| server.keepAliveTimeout = keepAliveTimeout; | |
| server.headersTimeout = 10000; | |
| server.requestTimeout = process.env.SERVER_REQUEST_TIMEOUT | |
| ? parseInt(process.env.SERVER_REQUEST_TIMEOUT, 10) | |
| : 600000; | |
| server.maxHeadersCount = 100; | |
| server.on("error", err => { | |
| logger.warn("Failed to launch server", err); | |
| throw new Error("Failed to launch server: " + new Error(err.message || String(err))); | |
| }); | |
| server.on("upgrade", async (req, socket, head) => { | |
| if (!req.url) { | |
| logger.warn("Bad request", req.url); | |
| return; | |
| } | |
| const parsedUrl = parse(req.url, true); | |
| const pathname = parsedUrl.pathname; | |
| try { | |
| if (!upgradeHandler) { | |
| logger.warn("-->", req.method, pathname, "No NextJS websocket upgrade handler"); | |
| return; | |
| } | |
| logger.warn("-->", req.method, pathname, "websocket upgrade"); | |
| await upgradeHandler(req, socket, head); | |
| } catch (err) { | |
| socket.destroy(); | |
| logger.warn(`Failed to handle upgrade request for ${pathname}`, err); | |
| } | |
| }); | |
| server.on("listening", async () => { | |
| logger.info( | |
| `Next app listening at http://${hostname}:${port} (${ | |
| dev ? "development" : process.env.NODE_ENV | |
| })` | |
| ); | |
| const nextBuild = fs.existsSync(".next/required-server-files.json") | |
| ? // @ts-ignore | |
| require("./.next/required-server-files.json") | |
| : undefined; | |
| /** @type {import('next').NextConfig | undefined} */ | |
| const config = /** @type {any} */ (nextBuild?.config); | |
| if (config?.output === "standalone") { | |
| if (dev) { | |
| logger.warn("NextJS standalone server is not supported in development mode"); | |
| process.exit(1); | |
| } | |
| // https://github.com/vercel/next.js/issues/64031 | |
| // process.env.__NEXT_PRIVATE_RENDER_WORKER = "yes"; | |
| process.env.__NEXT_PRIVATE_STANDALONE_CONFIG = JSON.stringify(config); | |
| const result = await initialize({ | |
| dir: ".", | |
| port, | |
| hostname, | |
| // @ts-ignore | |
| config, | |
| dev: false, | |
| minimalMode: false, | |
| server, | |
| isNodeDebugging: false, | |
| keepAliveTimeout, | |
| experimentalTestProxy: false, | |
| experimentalHttpsServer: false | |
| }); | |
| if (!result || !result.requestHandler || !result.upgradeHandler) { | |
| logger.warn("Invalid NextJS handlers", result); | |
| process.exit(1); | |
| } | |
| requestHandler = result.requestHandler; | |
| upgradeHandler = result.upgradeHandler; | |
| logger.info("NextJS standalone server ready, pid", process.pid); | |
| } else { | |
| /** @type {import("next/dist/server/next").NextServerOptions & import("next/dist/server/next").NextBundlerOptions} */ | |
| const options = { | |
| dev, | |
| customServer: true, | |
| hostname, | |
| port, | |
| httpServer: server, | |
| webpack: true | |
| }; | |
| let app; | |
| /** @type {any} override bad types in next.js */ | |
| const method = createNextServer; | |
| /** @type {(options: import("next/dist/server/next").NextServerOptions & import("next/dist/server/next").NextBundlerOptions) => Promise<import("next/dist/server/next").NextServer>} */ | |
| const createServer = method; | |
| app = await createServer(options); | |
| await app.prepare(); | |
| requestHandler = app.getRequestHandler(); | |
| upgradeHandler = app.getUpgradeHandler(); | |
| logger.info("NextJS server ready, pid", process.pid); | |
| } | |
| // https://pm2.keymetrics.io/docs/usage/signals-clean-restart/ | |
| if (process.send) { | |
| process.send("ready"); | |
| } | |
| }); | |
| const abortController = new AbortController(); | |
| server.listen({ | |
| port, | |
| hostname, | |
| signal: abortController.signal, | |
| ...(process.env.SERVER_REUSE_PORT === "1" ? { reusePort: true } : {}) | |
| }); | |
| /** @param {number | undefined} [timeout] */ | |
| const gracefulShutdown = async timeout => { | |
| logger.info("Graceful shutdown"); | |
| abortController.abort(); | |
| let connections = 0; | |
| let tasks = 0; | |
| let reportedStatus = 0; | |
| let started = Date.now(); | |
| do { | |
| connections = await new Promise((resolve, reject) => | |
| server.getConnections((err, count) => { | |
| if (err) { | |
| reject(err); | |
| } else { | |
| resolve(count); | |
| } | |
| }) | |
| ); | |
| if (Date.now() - reportedStatus > 2000) { | |
| logger.info("Wait for shutdown, connections", connections, "tasks", tasks); | |
| reportedStatus = Date.now(); | |
| } | |
| if (timeout && Date.now() - started > timeout) { | |
| logger.warn("Graceful shutdown: Timeout"); | |
| return false; | |
| } | |
| await new Promise(resolve => setTimeout(resolve, 250)); | |
| } while (connections > 0 || tasks > 0); | |
| logger.info("Graceful shutdown: Done"); | |
| return true; | |
| }; | |
| const shutdownHandler = () => { | |
| server.close(); | |
| if (pidFileName && fs.existsSync(pidFileName)) { | |
| try { | |
| fs.unlinkSync(pidFileName); | |
| } catch (_error) {} | |
| } | |
| }; | |
| process.on("SIGINT", async () => { | |
| if (shutdown) { | |
| return; | |
| } | |
| shutdown = true; | |
| logger.warn("SIGINT: Shutting down..."); | |
| await gracefulShutdown(gracefulShutdownTimeout); | |
| shutdownHandler(); | |
| process.exit(1); | |
| }); | |
| process.on("SIGTERM", async () => { | |
| if (shutdown) { | |
| return; | |
| } | |
| shutdown = true; | |
| logger.info("SIGTERM: Shutting down..."); | |
| await gracefulShutdown(gracefulShutdownTimeout); | |
| shutdownHandler(); | |
| process.exit(0); | |
| }); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment