Next.js App Router
Environment, Configuration, Deploying and Production Habits
Environment variables and what reaches the browser, next.config, building and running, deploying to Vercel and to a container, logging and monitoring.
On this page
- Words used in this lesson
- Environment variables: the load order
- Server variables and NEXTPUBLIC variables
- "Build time" is the trap
- next.config.ts: what lives there
- Build and run
- Three ways to run it
- Deploying to a platform
- Logging and monitoring
- A short production checklist
- Common mistakes
- Try it
- Interview questions
- Key takeaways
- What's next
In one line: Config is where most "works on my machine" bugs hide. In Next.js, a plain environment variable stays on the server, but a variable that starts with NEXT_PUBLIC_ is copied into the browser JavaScript when you build, and cannot change after that. Static pages read their variables at build time too. To ship the app, you run next build, then either next start, the standalone output, or a Docker image. I did all three, and the numbers and outputs are below.
A Next.js app has many settings: secrets, URLs, flags, headers, image rules, and the way you start the server. Most are easy. A few have sharp edges that cause real incidents: a secret that reaches the browser, a "production URL" that is frozen at the wrong value, or a Docker image that works on your laptop and fails in the cluster. This lesson goes through them in the order you meet them, and proves each claim.
This is lesson 8 of ten. Lesson 7 needed a site URL for metadataBase. Here you will see where that URL should come from. The next lesson, Next.js Interview Questions With Model Answers, uses everything in this series. For Docker itself (layers, caching, volumes), see Writing a Dockerfile. Here I only show the Next.js parts of a Dockerfile.
What I ran / checked. Linux, Node.js v22.22.0, Next.js 16.3.5 (this repo's version), React 19.3.0, Turbopack, Docker Engine 29.8.2 with the node:22-alpine image. I built a throwaway app in /tmp with .env files, a static page, a runtime page, a Client Component that prints its environment, a health endpoint, an error endpoint and an instrumentation.ts. I ran next build and next start, looked inside the build output with grep, ran the standalone server, and built and ran a real Docker image. I used curl and Playwright with Chromium for the checks. Sources I read (in my own words): inside node_modules/next/dist/docs/01-app/, the guides "Environment variables", "Self-hosting", "Production checklist", "Deploying", "Instrumentation", "Content Security Policy" (headings only), and the API pages for output, instrumentation, proxy and the CLI. I also read this blog's own next.config.ts and lib/site.ts (read-only). Not run: I did not deploy to Vercel or any other host, I did not test Kubernetes, a static export, a custom cache handler, or multiple instances behind a load balancer. For those I report what the docs say and mark it. One Docker detail: the sandbox has a proxy, so my lab build needed --network host, a copied CA file and a proxy argument. Those lines are not in the Dockerfile I show you, because you will not need them. Timings and sizes are from one machine.
Words used in this lesson
- Environment variable is a named value that lives outside your code, such as
DATABASE_URL. Programs read it at start or at run time. .envfile is a text file withNAME=valuelines. Next.js reads it intoprocess.env.- Build time is when you run
next build. Run time is when the server runs and answers requests. - Inline means to replace a variable with its value inside the code at build time, like find and replace.
NEXT_PUBLIC_is a name prefix. Variables that start with it are inlined into the JavaScript sent to the browser.- Secret is a value that must stay private: a database password, an API key, a session signing key.
next.config.tsis the settings file at the root of a Next.js project.- Standalone output is a build mode (
output: 'standalone') that makes a small folder with only the files needed to run the server. - Output file tracing means Next.js follows your
imports to find which files each page needs, so it can copy only those. - Docker image is a packaged app plus its runtime. A container is a running copy of an image.
- Multi-stage build is a Dockerfile that uses several
FROMsteps, so the final image does not carry build tools. - Instrumentation is code that sets up logging and monitoring when the server starts. The file is
instrumentation.ts. - Health check is a small endpoint that says "I am alive", used by a platform to decide if a container is working.
- Version skew means a browser running old code talks to a server running new code, after a deployment.
Environment variables: the load order
Next.js reads .env files for you. You do not need the dotenv package. The docs say the lookup goes in this order and stops at the first match:
process.env(the variables that are really set in the shell or the platform).env.$(NODE_ENV).local.env.local(not read whenNODE_ENVistest).env.$(NODE_ENV).env
NODE_ENV is development for next dev and production for next build and next start. Let me prove the order. I made three files, and put the same name, SHARED, in each:
.env GREETING=from-.env SHARED=env.env.production SHARED=env.production NEXT_PUBLIC_API_URL=https://api.prod.example.com.env.local SECRET_TOKEN=sk_live_super_secret_123 NEXT_PUBLIC_BUILD_LABEL=label-from-env-local SHARED=env.local(The sk_live_... value is made up for this lab.) next build told me which files it loaded, in order:
- Environments: .env.local, .env.production, .envAnd a static page that prints GREETING and SHARED showed:
GREETING=from-.env SHARED=env.local (read while building)GREETING exists only in .env, so it came from there. SHARED is in all three files, and .env.local won. Good: .env.local is meant for your own machine and for secrets. The create-next-app template adds .env*.local to .gitignore. This blog's .gitignore has the same line. The docs say you almost never want to commit these files.
Commit .env with safe defaults if you like, never commit .env.local or a real secret. A secret that was ever in git history must be treated as leaked, even if you delete it later. See Secrets, Encryption and Data Protection.
Server variables and NEXT_PUBLIC_ variables
Here is the rule from the docs: by default, an environment variable exists only on the server. If you want the browser to see it, you must start its name with NEXT_PUBLIC_. Next.js then inlines the value into the JavaScript during next build.
Let me show both halves in a real browser. I made a Client Component that prints three variables:
'use client'// app/leak/client.tsx (shortened)setTxt(JSON.stringify({ NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL, NEXT_PUBLIC_BUILD_LABEL: process.env.NEXT_PUBLIC_BUILD_LABEL, SECRET_TOKEN: process.env.SECRET_TOKEN,}))Then I opened the page with Playwright and read what the browser printed:
{"NEXT_PUBLIC_API_URL":"https://api.prod.example.com","NEXT_PUBLIC_BUILD_LABEL":"label-from-env-local"}Two keys, no SECRET_TOKEN. (JSON.stringify leaves out undefined, so the secret was undefined in the browser.) So a plain variable did not leak. I also searched the build output for the secret:
secret in .next/static: 0 filesZero files in the folder that is sent to browsers. Good.
Two warnings here.
First: the failure is silent. If you forget the prefix on a value the browser needs, you do not get an error. You get undefined, and a bug that shows up only in the browser. In the other direction, putting NEXT_PUBLIC_ on a secret is a leak. The name is the only guard. Review any NEXT_PUBLIC_ name as if it were printed on the page, because it is.
Second: a good guard exists for server code. Put import 'server-only' at the top of a file that uses secrets. If a Client Component imports it, even by accident, the build fails. I tested it, and the real output was:
Error: 'server-only' cannot be imported from a Client Component module> 1 | import 'server-only'It should only be used from a Server Component.Import traces: Client Component Browser: ./a8/lib/secret.ts [Client Component Browser] ./a8/app/leak2/page.tsx [Client Component Browser](You also saw this in lesson 5.) The docs also mention a taint option for marking a value as not allowed to reach the client. I did not test it.
"Build time" is the trap
Now the part that surprises people. I started the production server with different values from the ones I built with:
GREETING=changed-at-start SHARED=changed-at-start \NEXT_PUBLIC_API_URL=https://runtime-override.example.com next start -p 3109And then I asked for three things.
static: GREETING=from-.env SHARED=env.local (read while building)runtime: GREETING=changed-at-start SHARED=changed-at-start (read per request)- The static page still showed the old values. It was built during
next build, with the variables of that moment, and the file does not change. - The runtime page showed the new values. It is dynamic (it calls
await connection()), so it readsprocess.envon each request. A real environment variable beats the.envfiles, as the load order says.
And the browser variable:
files in .next/static with the build-time URL (api.prod.example.com): 1files in .next/static with the runtime URL (runtime-override): 0The NEXT_PUBLIC_API_URL I gave at start time was ignored. The browser code still had the value from the build. This is exactly what the docs say: after the build, the app no longer responds to changes in NEXT_PUBLIC_ variables. If you build one Docker image and promote it from "staging" to "production", the browser code will still point to the staging URL.
So there are two kinds of values, and you must keep them straight:
| Kind | Example | When the value is fixed | How to change it |
|---|---|---|---|
| Server variable, read in a dynamic route or route handler | DATABASE_URL | Each request (when process.env is read) | Restart the container with a new value |
| Server variable, read in a static page or at module load | A site name in a prerendered page | At build time | Rebuild |
NEXT_PUBLIC_ variable | NEXT_PUBLIC_API_URL | At build time, inside the JavaScript | Rebuild |
The docs give two ways out for "I need one image for many environments": (1) read the value on the server during dynamic rendering, after calling connection() (or any request API), and pass it to the browser as a prop, or (2) serve a small API that returns the config. The doc comment says it plainly: the variable is read at run time "meaning this env variable is evaluated at runtime".
A dynamic lookup is not inlined. The docs say that process.env[varName] and const env = process.env; env.NEXT_PUBLIC_X are not replaced at build time, because Next.js can only find the plain form process.env.NEXT_PUBLIC_X. In the browser those would be undefined. I did not test this one. I am quoting the docs.
How this blog does it. Its lib/site.ts builds the site URL from, in order: NEXT_PUBLIC_SITE_URL, then Vercel's VERCEL_PROJECT_PRODUCTION_URL, then VERCEL_URL, then http://localhost:3000. It also decides whether the deployment may be indexed, with process.env.VERCEL_ENV. This is a good pattern: one file reads the environment, with a sensible fallback, and the rest of the code imports a constant.
next.config.ts: what lives there
next.config.ts (or .js, .mjs) is in the project root. It exports an object. Most settings you will use fall into a few groups:
- Headers, redirects, rewrites. (
headers(),redirects(),rewrites(). Lesson 4 covers redirects and rewrites.) - Images.
images.remotePatterns,formats,qualities. (Lesson 7.) - Output.
output: 'standalone'or'export'. - Caching model.
cacheComponents: true. (Lessons 2 and 6.) - Server Actions.
serverActions.allowedOrigins. (Lesson 5.) - Small switches.
poweredByHeader,reactStrictMode,typedRoutes,experimental.*.
The config file runs only when the build or the server starts, so it can read process.env. I saw ✓ Running next.config.ts took 51ms in the output each time.
Headers are the most useful production setting. Mine:
const nextConfig: NextConfig = { output: 'standalone', poweredByHeader: false, async headers() { return [{ source: '/:path*', headers: [ { key: 'X-Content-Type-Options', value: 'nosniff' }, { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, ] }] },}What curl -i saw:
X-Content-Type-Options: nosniffReferrer-Policy: strict-origin-when-cross-originand there was no X-Powered-By: Next.js header, because poweredByHeader: false. That header tells everyone which framework you run, which helps attackers a little and helps you not at all.
This blog's next.config.ts is a nice real example of production headers. It sets a Content-Security-Policy (CSP), X-Frame-Options: DENY, X-Content-Type-Options: nosniff, Referrer-Policy, Strict-Transport-Security, Permissions-Policy and Cross-Origin-Opener-Policy. It turns the CSP off in development, because the dev tools need more than a strict policy allows. And a comment explains a trade-off: a per-request CSP nonce would force every page to be dynamic, so this fully static site allows inline scripts and limits the external origins instead. For the theory behind each header, see XSS, CSRF, Clickjacking and Security Headers and Frontend Security Checklist.
Build and run
Two commands, always in this order:
npm run build # next build: makes the production output in .next/npm run start # next start: serves itnext build prints the route table you know from the other lessons. It also type-checks your code and fails on errors, so run it in CI. The docs' production checklist says to run next build and next start locally before going live, because dev mode hides caching and performance.
Here is my build output for the lab app, shortened:
▲ Next.js 16.3.5 (Turbopack)- Environments: .env.local, .env.production, .env✓ Compiled successfully in 275msRoute (app)┌ ○ /├ ○ /_not-found├ ƒ /api/boom├ ƒ /api/health├ ○ /leak├ ƒ /runtime└ ○ /staticA build warning I found and fixed. My first instrumentation.ts printed a warning during the build:
Warning: A Node.js API is used (process.pid at line: 4) which is not supported in the Edge Runtime.instrumentation.ts is compiled for more than one runtime. Node-only code must be guarded with if (process.env.NEXT_RUNTIME === 'nodejs') { ... }. After I added that guard, the warning was gone. The docs show the same pattern for register.
Three ways to run it
1. next start
The simplest. You copy the project, run npm ci, npm run build, npm run start. It needs the whole node_modules folder (mine is 402 MB here). The docs say this server supports all Next.js features. It reads the PORT variable (or -p). The docs recommend putting a reverse proxy such as nginx in front, to handle slow clients, payload limits and rate limits.
2. Standalone output
With output: 'standalone', next build makes a folder .next/standalone with only the files the server needs. It uses output file tracing: it follows your imports and copies just those files and the used parts of node_modules. Compare the sizes:
67M .next/standalone592K .next/static402M node_modules (the full dev install)A 67 MB folder instead of 402 MB. It contains a tiny server.js. You start it with node server.js, not next start. I saw a message about this:
⚠ "next start" does not work with "output: standalone" configuration. Use "node .next/standalone/server.js" instead.(In my test, next start still answered requests, but I would follow the warning.) Two more things from the docs that I checked:
- The standalone folder does not copy
public/or.next/static, because a CDN should serve them. If you serve them from the same server, you copy them yourself. I copied them and ran:
cp -r public .next/standalone/a8/cp -r .next/static .next/standalone/a8/.next/cd .next/standalone/a8 && PORT=3110 HOSTNAME=127.0.0.1 GREETING=standalone-run node server.js▲ Next.js 16.3.5- Local: http://127.0.0.1:3110✓ Ready in 0ms[instrumentation] register() runs once at server start, pid 3956$ curl localhost:3110/api/health{"ok":true,"node":"v22.22.0","uptimeSeconds":2}$ curl localhost:3110/runtime -> GREETING=standalone-run SHARED=env.production (read per request)static asset: 200PORT and HOSTNAME control where it listens. GREETING=standalone-run was read at run time, as expected.
- Look at the path:
.next/standalone/a8/server.js, not.next/standalone/server.js. My lab app lives in a subfolder of a folder with itsnode_modules, so Next.js used the parent as the tracing root and kept the folder name. In a monorepo the docs say to setoutputFileTracingRoot, and the tracing root decides whereserver.jsends up. If you cannot findserver.js, look here first.
And an important detail about env files: in the standalone folder, I found .env and .env.production, but not .env.local. The server printed SHARED=env.production, not env.local. So the secrets in .env.local were not there. I did not dig into why. The lesson is to never depend on .env files in an image or a server. Set real environment variables in the platform.
3. A Docker image
The docs say Docker supports all features and show the standalone pattern. Here is the Dockerfile I used. It has three stages: install, build, and a small runtime image:
# syntax=docker/dockerfile:1FROM node:22-alpine AS depsWORKDIR /appCOPY package.json package-lock.json ./RUN npm ciFROM node:22-alpine AS buildWORKDIR /appCOPY --from=deps /app/node_modules ./node_modulesCOPY . .# NEXT_PUBLIC_* values are frozen into the browser JS here, at build time.ARG NEXT_PUBLIC_API_URL=https://api.example.comENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URLRUN npm run buildFROM node:22-alpine AS runWORKDIR /appENV NODE_ENV=productionENV PORT=3000ENV HOSTNAME=0.0.0.0RUN addgroup -S app && adduser -S app -G appCOPY --from=build --chown=app:app /app/.next/standalone ./COPY --from=build --chown=app:app /app/.next/static ./.next/staticCOPY --from=build --chown=app:app /app/public ./publicUSER appEXPOSE 3000CMD ["node", "server.js"]The key points:
- Three stages. The final image has no build tools, no source and no dev dependencies. Only the standalone output.
ARGforNEXT_PUBLIC_. These must be known at build time. Adocker run -e NEXT_PUBLIC_X=...is too late. I will prove it below.HOSTNAME=0.0.0.0. Inside a container, the server must listen on all interfaces, or you cannot reach it from outside.- A non-root user.
USER app, so a bug in the app does not run as root. - A
.dockerignorewithnode_modules,.next,.env.localand.git, so you do not send your secrets and your local build into the build context.
I built it:
docker build ... -t nb-lab:1 ....#16 [run 4/6] COPY --from=build --chown=app:app /app/.next/standalone ./#17 [run 5/6] COPY --from=build --chown=app:app /app/.next/static ./.next/static#18 [run 6/6] COPY --from=build --chown=app:app /app/public ./public#19 naming to docker.io/library/nb-lab:1 done (about 48 seconds on a cold cache)IMAGE DISK USAGE CONTENT SIZEnb-lab:1 332MB 84.9MBnode:22-alpine 238MB 61.1MB (the base image alone)The app adds about 94 MB on disk to the 238 MB base image. Now I ran it, and passed runtime variables, including a different NEXT_PUBLIC_API_URL:
docker run -d --name nb-run --network host \ -e PORT=3111 -e GREETING=from-docker-run \ -e NEXT_PUBLIC_API_URL=https://changed-at-run.example.com nb-lab:1▲ Next.js 16.3.5- Local: http://localhost:3111- Network: http://0.0.0.0:3111✓ Ready in 0ms[instrumentation] register() runs once at server start, pid 1$ curl localhost:3111/api/health{"ok":true,"node":"v22.23.3","uptimeSeconds":3}$ curl localhost:3111/runtime -> GREETING=from-docker-run SHARED=env.production (read per request)whoami inside the container: appfiles in /app/.next/static with the build ARG url (api.example.com): 1files in /app/.next/static with the runtime -e url (changed-at-run): 0Everything matches what I proved earlier:
- The container ran as user
app, not root. GREETING, a server variable, was read at run time from-e.- The browser variable was not changed by
-e. It stayed at the value from the build. - The server started as process
pid 1, so signals (like a stop request) reach it directly.
If you need one image for staging and production, do not put environment-specific URLs in NEXT_PUBLIC_ variables. Either build one image per environment (pass different --build-arg values), or have the server read the value at run time and hand it to the browser.
What I did not test: Kubernetes, health probes against this image, or running several copies of the container at once. The docs warn about three things there: the default cache is kept per instance (in memory and on disk), so a revalidation on one pod does not reach the others unless you configure a shared cache handler; all instances must share the same Server Actions encryption key (NEXT_SERVER_ACTIONS_ENCRYPTION_KEY), or users get "Failed to find Server Action" errors after a request lands on another instance; and a rolling deploy needs a deploymentId for version-skew protection, so old browsers do not request files that new servers no longer have.
Deploying to a platform
The docs list four options: a Node.js server (all features), a Docker container (all features), a static export (limited), and adapters for platforms. Vercel and Bun have verified adapters, according to the page I read. They also list templates for many hosts (Railway, Fly.io, Google Cloud Run, Render, DigitalOcean and others).
On a platform like Vercel, you connect the Git repository, the platform runs next build for you, and it serves static files from a CDN and runs dynamic routes as functions. You set environment variables in the platform's dashboard, per environment (production, preview). The platform also sets its own variables. This blog reads VERCEL_ENV and VERCEL_PROJECT_PRODUCTION_URL from them. I did not deploy anything, so I cannot show you output.
A static export (output: 'export') builds plain HTML, CSS and JS files that any web server can host, such as S3. The docs say it does not support features that need a server. That includes proxy.ts and server rendering per request. I did not run one. If your site is a pure blog, it is worth asking if you need more than that.
A last point from the docs' rendering guide: the finer-grained rendering model of Next.js (streaming, partial prerendering, on-demand revalidation) moves some complexity into the hosting platform. A single Node.js process supports every feature. Streaming needs the proxies and CDNs in front of it not to buffer the response. Nginx and similar proxies buffer by default, and the docs say to send the header X-Accel-Buffering: no to turn that off. If your streamed page seems to arrive all at once in production but not locally, look for a buffering proxy.
Logging and monitoring
When a production server breaks, you have two questions: what happened, and how often? Next.js gives you one hook for the first: instrumentation.ts in the project root. It can export two functions:
register()runs once, when a new server instance starts, before it takes requests. Use it to start your monitoring SDK. The docs showregisterOTel('next-app')from@vercel/otelfor OpenTelemetry.onRequestError(error, request, context)runs when the server captures an error. Use it to send errors to your error tracker.
Mine just prints to the console. I also made a route that throws, to trigger it:
// instrumentation.tsexport function register() { if (process.env.NEXT_RUNTIME === 'nodejs') console.log('[instrumentation] register() runs once at server start, pid', process.pid)}export const onRequestError: Instrumentation.onRequestError = async (err, request, context) => { const message = err instanceof Error ? err.message : String(err) console.log('[instrumentation] onRequestError', JSON.stringify({ message, path: request.path, method: request.method, routeType: context.routeType, routePath: context.routePath }))}$ curl -s -o /dev/null -w "%{http_code}\n" localhost:3109/api/boom500and the server log:
[instrumentation] register() runs once at server start, pid 3723⨯ Error: boom from route handler at <unknown> (.next/server/chunks/[root-of-the-server]__0q20b9r._.js:1:1073)[instrumentation] onRequestError {"message":"boom from route handler","path":"/api/boom","method":"GET","routeType":"route","routePath":"/api/boom"}The hook got the message, the path, the method and, from context, that it was a route (not a page render or an action). In a real app, you would send that to an error tracker. The docs warn that the error object may not be the original: React can change it during Server Component rendering, and you can use its digest to match it to the log line. They also say to await any async work inside the hook.
Other habits that need almost no code:
- A health endpoint. I made
app/api/health/route.tswithexport const dynamic = 'force-dynamic'. It returned{"ok":true,"node":"v22.22.0","uptimeSeconds":7}. A platform can call it to see if the process is alive. Keep it cheap: do not make it call your database unless you also want "ready" checks. - Log to stdout. In a container,
console.loggoes to the platform's log system. Do not write log files inside the container. - Web vitals from real users. Use the
useReportWebVitalshook (lesson 7). - Do not log secrets. A request header or an environment object can contain them. See Logging and Observability.
A short production checklist
This is my own list, based on what I proved above and on the docs' checklist. Tick each one before you go live.
next buildpasses in CI, and I read the route table. Each route is what I expect.- I ran
next start(or the standalone server, or the image) and clicked through. Not justnext dev. - No secret has a
NEXT_PUBLIC_name. Files that use secrets importserver-only. .env*.localis in.gitignore. No.envfile is needed inside the image. Real variables are set on the platform.- Every
NEXT_PUBLIC_value is correct at build time for that environment. - Metadata has
metadataBase, and previews are not indexable. - Security headers are set (
headers()or a CDN), andpoweredByHeaderis off. - Images have sizes and
remotePatterns. Fonts come fromnext/font. app/error.tsx,app/not-found.tsxandapp/global-error.tsxexist (the docs recommend a global error page and a global 404).- Errors reach my tracker through
onRequestError, or an SDK inregister(). - Multiple instances: shared cache handler, same encryption key, a
deploymentId. - A buffering proxy does not break streaming.
- The container runs as a non-root user, listens on
0.0.0.0, and I know how it is stopped.
Common mistakes
- Using
NEXT_PUBLIC_on a secret. - Forgetting the prefix on a value the browser needs, and getting
undefined. - Expecting
docker run -e NEXT_PUBLIC_X=...to change browser code. - Reading
process.envin a static page and expecting it to follow the environment at run time. - Committing
.env.local, or copying it into an image. - Using
process.env[name]with a variable name for a public variable. HOSTNAMEnot set to0.0.0.0in a container, so the app is not reachable.- Forgetting to copy
public/and.next/staticnext to a standaloneserver.jsand no CDN serving them. - A single
Dockerfilestage that keeps all ofnode_modulesand the source in the final image. - Running several instances with the default per-instance cache and expecting
revalidateTagto reach all of them. - Putting Node-only code in
instrumentation.tswithout theNEXT_RUNTIMEcheck. - Testing production behavior only in dev.
Try it
- Make
.env,.env.productionand.env.local, each withSHARED=and a different value. Runnext buildand look at the line that starts withEnvironments:. Which value does a static page show? - Make a static page and a page that calls
await connection()that both printprocess.env.GREETING. Build, then start withGREETING=new npm run start. Which page changes? - Make a Client Component that prints
process.env.NEXT_PUBLIC_Xandprocess.env.SECRET_X. What does the browser show for each? - Build with
NEXT_PUBLIC_X=one, then start withNEXT_PUBLIC_X=two. What does the browser show? How can you check the JS files? - Make a file with
import 'server-only'and import it from a Client Component. What happens at build time? - Add
output: 'standalone'and build. How big is.next/standalone? Run it withnode .../server.jsandPORT=4000. - Add an
instrumentation.tswithregisterandonRequestError. Make a route that throws. What do you see in the log? - (Optional) Write the three-stage Dockerfile. Build it and run it with
-efor one server variable and oneNEXT_PUBLIC_variable. Which one changes?
Answers
- The line lists the files in order. A variable that is in several files shows the value from the most specific one:
.env.localbefore.env.productionbefore.env. - Only the
connection()page. The static page was built before the start, with the old value. NEXT_PUBLIC_Xshows its value.SECRET_Xisundefined.- It shows
one. Search the files in.next/staticwithgrep. You findone, and you do not findtwo. - The build fails with "'server-only' cannot be imported from a Client Component module" and an import trace.
- Usually tens of MB, much smaller than a full
node_modules. It listens on port 4000. - The
registerline once at start, and anonRequestErrorline for each error, with the message, path and route type. - The server variable changes. The
NEXT_PUBLIC_one does not, because it was frozen into the JavaScript at build time.
Interview questions
How do environment variables work in Next.js? (Must know)
Next.js loads .env files into process.env. The lookup order is the real environment, then .env.$(NODE_ENV).local, then .env.local, then .env.$(NODE_ENV), then .env, and it stops at the first match. By default, variables are only available on the server. Variables that start with NEXT_PUBLIC_ are inlined into the browser JavaScript at build time. I proved the order with a variable in three files: .env.local won.
What does NEXTPUBLIC really do, and what is the catch? (Senior signal)
At build time, Next.js replaces process.env.NEXT_PUBLIC_X with the literal value in the JavaScript it sends to the browser. The catch is that it is frozen. I built with one URL and started with another, and the browser files still had the build-time URL. So one Docker image cannot serve several environments if the browser needs different values. I either build per environment, or read the value on the server at run time and pass it down, or serve it from a small API.
Can a normal server variable leak to the browser? (Trap)
Not by itself. In a Client Component, process.env.SECRET is undefined, and I found zero copies of my test secret in the .next/static folder. The leaks come from: a NEXT_PUBLIC_ name on a secret, passing a secret as a prop from a Server Component to a Client Component, or logging it. I add import 'server-only' to files that use secrets, which makes the build fail if a Client Component imports them. I tested that.
A page shows an old value of an environment variable after I changed it on the server. Why? (Trap)
If the page is static, it was built at build time, so process.env was read once, then. Changing the variable later changes nothing until the next build. I started a server with a new GREETING: the dynamic page used it and the static page did not. To read it at run time, the route must be dynamic (for example, call connection() or use a request API). Or I rebuild after changing it.
What is output: 'standalone', and when would you use it? (Senior signal)
It makes .next/standalone, a small folder with only the files the server needs, found by tracing your imports, plus a server.js to start it with node. My lab went from a 402 MB node_modules to 67 MB. I use it for Docker images. It does not copy public/ or .next/static, so I copy them or serve them from a CDN. In a monorepo I set outputFileTracingRoot, which also decides where server.js ends up.
How would you containerize a Next.js app? (Must know)
A multi-stage Dockerfile: one stage installs dependencies, one runs next build with output: 'standalone', and a last small stage copies the standalone folder, .next/static and public, runs as a non-root user, sets HOSTNAME=0.0.0.0 and PORT, and starts node server.js. Build-time values such as NEXT_PUBLIC_ go in as ARGs. I built one: the image was 332 MB on disk with a 238 MB base. A .dockerignore keeps .env.local and node_modules out of the build context.
I changed NEXTPUBLICAPIURL with docker run -e, and the browser still calls the old URL. Why? (Trap)
Because the value was inlined into the JavaScript during next build, which ran when the image was built. docker run -e changes only the process environment at run time. I tested it: the build-time URL was in one JavaScript file and the run-time URL was in none. The fix is a rebuild with a different --build-arg, or reading the value on the server at run time.
What do you put in next.config.ts for production? (Senior signal)
Security headers through headers(), poweredByHeader: false, images.remotePatterns and format settings, redirects, output: 'standalone' for containers, and any caching flags. I verified the headers with curl -i, and that X-Powered-By disappeared. This blog turns off its strict CSP in development, because dev tools need more than the policy allows.
How do you monitor errors and start monitoring in a Next.js server? (Senior signal)
With instrumentation.ts in the project root. register() runs once when a server instance starts, and I use it to start a monitoring SDK such as OpenTelemetry. onRequestError runs when the server captures an error and gives me the error, the request path and method, and a context with the route type (render, route, action or proxy). I confirmed it with a route that throws. I guard Node-only code with process.env.NEXT_RUNTIME === 'nodejs', because the file is also compiled for the edge runtime and the build warned me when I did not.
What changes when you run several instances of a Next.js app? (Senior signal)
Three things, from the docs. The default cache is per instance, so revalidateTag on one does not reach the others unless I configure a shared cache handler. All instances need the same NEXT_SERVER_ACTIONS_ENCRYPTION_KEY, or an action encrypted by one instance fails on another with "Failed to find Server Action". And rolling deploys need a deploymentId to protect against version skew. I did not run multiple instances myself, so this part is from the documentation.
Why does a streamed page arrive all at once in production? (Trap)
Something between the server and the browser buffers the response. Nginx and similar reverse proxies buffer by default. The docs say to set the header X-Accel-Buffering: no (for example from next.config.ts headers) so the proxy passes chunks through. A CDN can do the same. I measured chunks arriving over time when I connected directly to the Node server, so I know the server was not the problem.
What is a good health check for a Next.js container? (Senior signal)
A tiny route handler that returns 200 and a small JSON, marked dynamic so it runs on every request. It tells the platform the process is alive and can answer. I keep it cheap and separate from a deeper "ready" check that touches the database, because a database blip should not make the platform kill every container. I tested a version that returned {"ok":true,"uptimeSeconds":...}.
Key takeaways
- Load order: real environment, then
.env.$(NODE_ENV).local,.env.local,.env.$(NODE_ENV),.env. I proved it with one variable in three files. - Server variables stay on the server.
NEXT_PUBLIC_variables are copied into the browser JavaScript at build time and never change after that. I tried to override one at start time and failed. - Static pages read the environment when you build. Dynamic routes read it on each request.
- Use
import 'server-only'for files with secrets. A wrong import fails the build with a trace. next.config.tsholds headers, images, output mode and flags. Turn offpoweredByHeader. Set security headers.output: 'standalone'cut my deployable folder from 402 MB to 67 MB. Start it withnode server.js, and copypublicand.next/staticyourself.- A Docker image: three stages, non-root user,
HOSTNAME=0.0.0.0,ARGfor build-time values. Mine was 332 MB and behaved as proved. instrumentation.tsgives youregisterandonRequestError. Guard Node-only code withNEXT_RUNTIME.- With many instances, plan for a shared cache, one encryption key and a deployment ID. I did not test those.
- I did not deploy to a host. Test your own platform, and always run a production build first.
What's next
Next: Next.js Interview Questions With Model Answers. The final lesson gathers the questions that interviewers really ask about this whole series: rendering, caching, Server Components, actions, auth, metadata and deployment, with answers and the traps. Practise now: build the lab Dockerfile for any small Next.js app, run it with -e for two kinds of variable, and check which one changes.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related lessons
- Observability and Guardrails for LLM Features in Production · AI Engineering
- Slow First Load: Bundles, Code Splitting and LCP · Scenario Debugging
- Concurrent React, Suspense and Server Components in Next.js · Senior Frontend Depth
Part of the study guide
Related series