This bug had me stumped for the better part of an afternoon. On a Next.js site I built, the analytics tag never showed up in production — even though NEXT_PUBLIC_GA_ID was clearly filled in under the hosting dashboard's environment variables, with the correct value. It worked locally. In production: undefined.
What made it even stranger: I checked the runtime environment on the server, and the variable was there. But in the bundle shipped to the browser, the value was empty.
The cause is something that's widely misunderstood about Next.js: NEXT_PUBLIC_* isn't read at runtime — it's substituted at build time.
NEXT_PUBLIC_* is a compile-time string replacement
When you run next build, Next.js looks for every occurrence of process.env.NEXT_PUBLIC_XXX in your code and replaces the text directly with the literal value present at the moment the build runs. The result is hard-coded into the JavaScript bundle.
Which means: the value that "sticks" is whatever was available when next build executed — not when the app runs. If you fill in the variable on the dashboard after the build (or the build used a stale cache without that variable), your bundle already contains undefined. Changing the runtime env afterward changes nothing, because there's no process.env left to read in the browser — there's only the string that was already baked in.
This is exactly why "the variable exists on the server but is undefined in the browser" happens: server runtime and build time are two different moments.
The fix: a genuinely fresh rebuild
The solution isn't to touch the code, but to make sure the variable is available at build time:
- Make sure
NEXT_PUBLIC_*is set in the environment beforenext buildruns (in the build scope, not just runtime). - Trigger a genuinely fresh build — not a "redeploy" that reuses the previous build cache. Many platforms have a "Redeploy" button that reuses old artifacts; that won't embed the new variable. Look for a "Clear build cache" option, or push an empty commit to force a full build.
# Verify the value actually made it into the bundle (not undefined)
# after the build, grep the client bundle:
grep -r "G-XXXXXXX" .next/static/ | headIf the string shows up in .next/static/, it baked in correctly. If it's not there, the variable wasn't available at build time.
The 5-minute diagnostic: code or infra?
When I'm stuck, the fastest trick to separate "code problem" from "environment problem" is to temporarily hardcode the value on staging:
// Temporary, ONLY for diagnosing on staging:
const gaId = process.env.NEXT_PUBLIC_GA_ID ?? "G-HARDCODED-TEST";- If the tag shows up with the hardcoded value → your code is correct, and the problem is in provisioning the environment at build time.
- If it still doesn't show → the problem is in your code/component, not the environment.
Once you know which, remove the hardcode. These five minutes save you hours of guessing.
What to remember
NEXT_PUBLIC_*= build time, baked into the bundle. Variables without theNEXT_PUBLIC_prefix are only available on the server (Server Component / route handler /getServerSideProps) at runtime — those are genuinely safe to read at runtime.- "Redeploy" ≠ "rebuild". If the platform uses a build cache, the new variable doesn't come along. Force a full build.
- Don't put secrets in
NEXT_PUBLIC_*— because they're baked into the bundle, the value is publicly visible in the browser. For secrets, use a server env without the prefix.
Once you understand that NEXT_PUBLIC_* is "photographed" at build time and never re-read in the browser, the "I set it but it's undefined" bug starts to make sense — and the fix is always the same: make sure it's present at build time, then do a fresh rebuild.
