Blog
Programming challenges, experiments, and build notes behind short videos.
Programming challenges, experiments, and build notes behind short videos.

A while back, the blog you are reading right now went live with every CMS-powered page completely empty. Not broken — empty. No error page, no 500, no failed deploy. The build was green, the container came up healthy, the health check returned 200, and the pages rendered a hero, a header, a footer… and none of the actual content.
It took me embarrassingly long to figure out why, and when I did, I realized no tool existed that would have caught it. So I built one. It is called next-env-audit, it is now on npm, and this post is about the bug class it hunts and the weird things I learned about Next.js build output while writing it.
The setup is the standard headless pattern: Next.js App Router frontend, Strapi CMS behind it, and a CMS_API_TOKEN the server uses to fetch content. The token was configured as a runtime variable — it lived on the server, in the container environment, and was deliberately kept out of the build.
Here is the part that bites: in the App Router, server components render statically by default. Unless a route opts out — a dynamic API like cookies(), export const dynamic = "force-dynamic", or ISR — Next.js runs it once, at build time, and freezes the resulting HTML. My CMS routes did not opt out. So at build time, Next.js executed the page, the fetch ran without the token, the CMS said no, my code fell back to an empty list, and Next.js happily prerendered that empty state as the permanent version of the page.
Every layer did exactly what it was told. The build does not fail when a fetch inside a prerendered page fails — why would it, my code handled the error. The deploy does not fail — the pages exist. The bug only exists in the gap between two mental models: "this variable is provided at runtime" and "this page runs at build time." When those two sentences are both true about the same route, you ship frozen emptiness.
Once I knew what to look for, I found the same shape everywhere. It comes in two flavors:
process.env variable. Whatever value that variable had at build time (including undefined) is baked into the HTML forever. This is my empty-pages incident.NEXT_PUBLIC_* variable is inlined into the client JavaScript as a literal at build time. If you build a Docker image once and promote it through staging and production — the classic "build once, deploy many" — all environments get staging's values, because the values are not configuration anymore, they are source code.The second flavor is one of the most-discussed pain points in the Next.js repo (the runtime-env discussion has hundreds of upvotes), and the existing tooling all sits before the build: t3-env validates that variables are defined and well-formed, next-runtime-env works around the inlining for Docker setups. Both are great. Neither can tell you what actually ended up inside your build output. That after-the-fact gap is what next-env-audit fills.
You run it right after next build, in the same environment, and it audits the .next directory directly — no code changes, no wrapper, no config required to start:
next build && npx next-env-auditIt cross-references the prerender manifests (which routes are static, which are ISR, which are dynamic) against the compiled server chunks for each route, looking for process.env reads that survived compilation. Then it scans the client chunks for NEXT_PUBLIC_* values that were inlined — and for references that were not inlined because the variable was missing at build time, which is the client-side version of my incident: undefined quietly shipped to every browser.
Here is the fun part: this is the report for the very site you are reading, today, after the fix:
next-env-audit v0.1.0
audited gapchix.web/.next — 9 routes, 5 prerendered
server bake · env vars read by statically prerendered routes
ℹ / reads CMS_API_TOKEN — value frozen at build time (ISR, revalidate 60s)
Revalidation re-reads the variable at runtime, so this self-heals — flagged for awareness.
client bake · NEXT_PUBLIC_* values in browser bundles
ℹ NEXT_PUBLIC_CMS_API_BASE_URL — set in the build env, value not found in any client chunk
ℹ NEXT_PUBLIC_PROD_CMS_API_BASE_URL — set in the build env, value not found in any client chunk
0 errors · 0 warnings · 3 infoNote the severity on that first finding: info, not error. The homepage uses ISR with a 60-second revalidation window, and ISR re-runs the render — with the real runtime environment — after deploy. A frozen-at-build-time value on an ISR route heals itself within one revalidation cycle, so the auditor deliberately softens it. On a fully static route, that same finding is an error, because nothing will ever re-read the variable. Severity follows the actual failure mode, not the pattern match.
By default the tool only reports. If you want it to gate CI, you opt in with --fail-on server-bake,client-bake, and there is an allowlist config for the legitimate cases — build stamps, generateStaticParams data sources, intentional build-time flags — where you record why a bake is fine:
{
"allow": [
{ "route": "/", "var": "BUILD_INFO", "reason": "intentional build-time stamp" }
]
}Report-only by default was a deliberate choice. A heuristic tool that hard-fails builds out of the box gets uninstalled the first time it is wrong; one that shows you what it found and lets you decide earns the right to gate CI later.
The original plan was straightforward: scan compiled chunks for process.env.SOMETHING, because property access survives minification. That worked perfectly on webpack output. Then I ran it on a Next 16 Turbopack build and got… nothing. Zero findings, on a fixture app built to be full of violations.
Two discoveries later:
process.env at all. References to missing NEXT_PUBLIC_* variables are rewritten through a process polyfill and come out looking like d.default.env.NEXT_PUBLIC_X. The scanner now anchors on the .env.NEXT_PUBLIC_* property chain instead of the process prefix, which survives both bundlers' rewrites."server/chunks/ssr/…" — gives clean per-route attribution, and the same trick works for webpack's require("../chunks/123.js") style.The lesson generalizes: if you build tooling against Next.js build output, test against both bundlers on every change. The CI matrix runs the integration suite against next@latest and next@canary for exactly this reason — Turbopack internals are not a stable API, and I would rather find out from a red canary job than from an issue report.
The first run on a real production app (this one) immediately produced a false positive: a warning that some route read process.env.DEBUG. It did — but not in my code. The ubiquitous debug package reads DEBUG at import time, and it sits in half the dependency trees on npm. That finding became a built-in ignore list for ecosystem diagnostics — DEBUG, CI, NO_COLOR, FORCE_COLOR, terminal toggles — on top of the obvious framework internals like NEXT_* and NODE_ENV.
And one bonus lesson from the publishing step, for anyone shipping a CLI to npm right now: npm 11's strict validation silently removes a bin entry whose path starts with ./ — it appears only as a warning buried in the --dry-run output. I caught it in the dry run; if I had not, the package would have installed fine everywhere while npx next-env-audit did nothing at all. Read your dry-run warnings.
If you run Next.js with a CMS, secrets, or Docker-promoted images, the audit takes ten seconds:
next build && npx next-env-auditThe code is MIT-licensed and lives at github.com/gapchix/next-env-audit, with docs, recipes, and the full findings reference in the README. If it flags something real in your app — or flags something it should not have — open an issue. The ignore list got smarter from dogfooding on one production app; yours will make it smarter still.
As for this site: it gets audited now, and the pages you are reading are demonstrably not empty. Progress.

