Skip to content

DEPLOYMENT

Vercel Environment Variables: Build Time vs Runtime

Most Vercel environment variables problems are one bug: the value was read at build time and you changed it after. Here is which reads are frozen, and the 5 KB Edge limit.

Almost every Vercel environment variables problem is the same problem wearing a different error: the value was captured at build time and you changed it afterwards. Vercel's own documentation is blunt about it — changes "are not applied to previous deployments, they only apply to new deployments" — and Next.js goes further, turning every NEXT_PUBLIC_ value into a literal inside your JavaScript bundle. This shows how to tell which of your variables are frozen and which are read per request, using the eleven this site runs on, audited on 2026-08-11 against Next.js 15 and Vercel's documentation as of 2026-06-16.

Key takeaways

  • A dashboard change needs a redeploy, not a restart. Existing deployments keep the values they were built with.
  • NEXT_PUBLIC_ is a build-time inline, not a runtime lookup. The value becomes a literal in shipped JavaScript, so it is public forever and stale until the next build.
  • The Edge limit is 5 KB per variable, not 64 KB. Reachability from middleware.ts decides which cap applies, and no dashboard shows you that.
  • process.env read through an alias is invisible to the inliner. Next.js documents this, and it silently breaks public variables.
  • Fail closed. Read the whole credential set together and return null on any gap, so a misconfigured deploy answers 503 instead of throwing per request.

The one bug behind most of them

There are two moments a variable can be read, and they behave nothing alike.

Build time. next build runs, and every reference to process.env.NEXT_PUBLIC_ANYTHING in code that reaches the browser is replaced with a hard-coded value. Next.js's documentation states that after building, "your app will no longer respond to changes to these environment variables" and that all such values "will be frozen with the value evaluated at build time." Your next.config.mjs is in this category too — it executes during the build, so anything it reads is baked into the compiled configuration.

Request time. A Server Component that opts into dynamic rendering, a route handler, a Server Action: these run per request in a Node process that has the deployment's variables in its environment. process.env.CLOUDFLARE_D1_API_TOKEN there is a real lookup.

This site has exactly one variable in the first category and it is the one that reads oddly:

next.config.mjs
const r2Hostname = normalizeR2Hostname(process.env.NEXT_PUBLIC_R2_HOSTNAME);

That value feeds images.remotePatterns and the URL builder in lib/images.ts, both from the same normaliser so they cannot disagree. Change the bucket hostname in the Vercel dashboard and nothing happens: the allowlist was compiled and the URLs were inlined. The site keeps serving the old host until something triggers a build. The wider architecture this sits in is covered in building a modern Next.js site with AI assistance; what matters here is that "it worked when I set it" and "it works now" are different claims.

Auditing your own surface

The dashboard lists names and environments. It cannot tell you which code reads which variable, and that is the fact you need. scripts/check-env.mjs walks the source, records every process.env.NAME, then builds an import graph from middleware.ts and next.config.mjs to work out each variable's reach:

Terminal
npm run check:env

# → Environment surface — 11 variables
# →
# →   VARIABLE                    EXPOSURE  REACH          READ IN
# →   ADMIN_PASSWORD              secret    edge           lib/admin/config.ts
# →   ADMIN_SESSION_SECRET        secret    edge           lib/admin/config.ts
# →   ADMIN_USERNAME              secret    edge           lib/admin/config.ts
# →   CLOUDFLARE_ACCOUNT_ID       secret    tooling+node   lib/d1.ts, scripts/upload-r2.mjs
# →   CLOUDFLARE_D1_API_TOKEN     secret    node           lib/d1.ts
# →   CLOUDFLARE_D1_DATABASE_ID   secret    node           lib/d1.ts
# →   MCP_PROBE_TOKEN             secret    tooling        scripts/mcp/_probe-env.mjs
# →   NEWSLETTER_IP_SALT          secret    node           lib/newsletter.ts
# →   NEXT_PUBLIC_R2_HOSTNAME     public    build+node     next.config.mjs, lib/images.ts
# →   R2_API_TOKEN                secret    tooling        scripts/upload-r2.mjs
# →   R2_BUCKET                   secret    tooling        scripts/upload-r2.mjs

One public variable out of eleven is the number to aim for. The check also fails the run when .env.example and the code disagree in either direction — a key declared in the template that nothing reads is config nobody dares delete, and a key the app reads that the template omits is the one that works on your machine and 500s on the first deploy.

The script's own first run got two things wrong, and both are worth stealing. It reported a variable named C, because an uppercase-only character class matched process.env.ComSpec up to the first lowercase letter and stopped — a one-letter row plausible enough to survive review. And it reported R2_BUCKET and R2_API_TOKEN as read by nothing, because scripts/upload-r2.mjs reaches them through const env = { ...loadEnv(), ...process.env } rather than a literal member expression.

That second bug is the interesting one, because it is the same blind spot Next.js's own inliner has, for the same reason, and Next.js documents it. A regex over process.env.X and a bundler doing static replacement both need the property access to be written literally. The scanner now detects single-line aliases and reports them; a value returned from a helper function is still out of reach, which the script says in a comment rather than pretending otherwise.

The three limits that actually bite

LimitValueApplies to
Total per deployment64 KBAll variables combined
Per variable, Edge5 KBEdge Functions and middleware
Redaction threshold32 charactersSensitive values in build logs

The middle row is the one that surprises people, and it is invisible in the dashboard. Three of this site's variables are read by lib/admin/config.ts, which is imported by lib/admin/session.ts, which is imported by middleware.ts — so they run on the Edge runtime and live under the 5 KB cap rather than the 64 KB one. Nothing about ADMIN_SESSION_SECRET announces that. It is a property of the import graph, which is why the audit above computes reachability instead of guessing from filenames.

That constraint also explains a design decision that otherwise looks careless: lib/admin/config.ts is deliberately not marked server-only, because the Edge runtime does not reliably resolve that export condition, and the same module has to load in middleware and in Node-side Server Components. The compensating control is that nothing client-side imports it.

Sensitive variables are worth turning on for tokens. Vercel stores them unreadable, so no one with project access can read the value back, and any value of 32 characters or more is replaced with [REDACTED] in build logs. Two constraints come with it: they are unavailable in the Development environment, and you cannot recover a value, only replace it.

Which file wins locally

Next.js resolves a variable through five places in order and stops at the first hit:

  • process.env — a real shell variable beats every file.
  • .env.$(NODE_ENV).local — for example .env.development.local.
  • .env.local — not loaded when NODE_ENV is test.
  • .env.$(NODE_ENV)
  • .env

So .env.local overrides .env, and both are loaded. This is also where vercel env pull fits: it writes a .env file, which Vercel's docs describe as serving "the same purpose as .env.local". Both statements are true and together they are a trap.

On this machine, npm run check:env --values reports .env.local absent and .env holding ten keys. The site runs fine, because Next reads .env at rank five. But scripts/upload-r2.mjs hand-rolls a reader for .env.local only — it runs outside Next, so it gets no env loading for free — and therefore finds nothing and reports the R2 credentials missing while the application using the same account works. The fix is not to pick a filename. It is for every script that runs outside the framework to read the same set the framework does:

scripts/check-d1.mjs
function loadEnv() {
  const out = {};
  for (const name of [".env", ".env.local"]) {
    // …later files win, matching Next's precedence
  }
  return { ...out, ...process.env };
}

Reading a variable safely

The pattern this site uses everywhere is to read the whole credential set in one function and return null rather than a partial object:

lib/admin/config.ts
export function readAdminConfig(): AdminConfig | null {
  const username = process.env.ADMIN_USERNAME?.trim();
  const password = process.env.ADMIN_PASSWORD;
  const secret = process.env.ADMIN_SESSION_SECRET?.trim();

  // Password is intentionally not trimmed: leading or trailing whitespace is a
  // legitimate part of a generated passphrase, and silently stripping it would
  // make a correct password fail for reasons nobody could diagnose.
  if (!username || !password || !secret) return null;
  if (secret.length < MIN_SECRET_LENGTH) return null;

  return { username, password, secret };
}

Four decisions in twelve lines, each earned:

  • ?.trim() on everything except the password. Pasting into a dashboard field picks up whitespace constantly. Trimming a password would break a legitimate passphrase instead.
  • All-or-nothing. A caller can never hold a config with two of three fields set.
  • Validated, not just present. A 12-character signing secret is worse than none, so the length floor is checked at read time rather than trusted.
  • Callers translate null into a 503. lib/d1.ts does the same via isD1Configured(), so a deploy missing its database credentials returns a clean unavailable from the newsletter route instead of throwing on every request.

Scoping tokens per service

Two API tokens in that list point at the same Cloudflare account and are not interchangeable. CLOUDFLARE_D1_API_TOKEN carries "D1: Edit"; R2_API_TOKEN carries "Workers R2 Storage: Edit". The D1 token returns 403 against the R2 REST API, which reads as a broken script rather than a scope error and is recorded in .env.example next to the variable so nobody rediscovers it.

Two narrow tokens rather than one broad one costs an extra dashboard entry and buys a blast radius: the token that uploads images cannot read the subscriber table. That is the same reasoning applied to team rollouts in AI coding tools for teams, and it is worth applying before you have anything worth protecting, because retrofitting scope onto a token already pasted into three places is the harder job.

Common mistakes

  • Changing a value and expecting the running deployment to notice. It will not. Redeploy.
  • Prefixing something with NEXT_PUBLIC_ to "make it work". It works because the value is now in your client bundle, readable by anyone. Check what you just published.
  • Reading process.env through an alias for a public variable. Not inlined, no error, absent in the browser.
  • Assuming the 64 KB limit applies everywhere. Anything reachable from middleware is capped at 5 KB per variable.
  • One token for every service. Scope them separately or the first leak is total.
  • Letting a script invent its own .env reader. It will pick a different filename from the framework and disagree with it six weeks later.
  • Setting variables only for Production. Preview deployments then fail in ways production never reproduces, which is the worst place to find out.

Conclusion

Run an audit that computes reach rather than reading the dashboard: you want to know which variables are frozen into the bundle, which are read per request, and which cross into Edge and its 5 KB cap. Keep exactly one NEXT_PUBLIC_ variable if you can, read credential sets all-or-nothing so a bad deploy fails closed, and scope one token per service. If you are wiring the deployment itself, deploying a Next.js site to Vercel covers the build and DNS side, and running Cloudflare D1 on Vercel picks up where three of these variables are actually spent.

Frequently asked questions

Why is my Vercel environment variable undefined in production?
Two causes cover almost all of it. Either the variable is not prefixed NEXT_PUBLIC_ and you are reading it in the browser, where non-public values never exist; or you changed it in the dashboard after the deployment was built. Vercel states that changes "are not applied to previous deployments, they only apply to new deployments" — you need a redeploy, not a restart.
Do I need to redeploy after changing an environment variable on Vercel?
Yes. Vercel applies variable changes only to new deployments, so an existing deployment keeps the values it was built and provisioned with. For NEXT_PUBLIC_ variables the effect is stronger still: the value is inlined into the JavaScript bundle at next build, so it is a literal in shipped code rather than a lookup that could pick up a new value.
What is the environment variable size limit on Vercel?
64 KB total per deployment across all variables combined, on Node.js, Python, Ruby, Go and the PHP community runtime. The catch is the Edge runtime: Edge Functions and middleware are limited to 5 KB per environment variable. If a variable is reachable from middleware.ts, it lives under the 5 KB cap, not the 64 KB one.
What is the difference between .env and .env.local in Next.js?
Load precedence. Next.js checks process.env, then .env.$(NODE_ENV).local, then .env.local, then .env.$(NODE_ENV), then .env, and stops at the first hit — so .env.local overrides .env. Both are loaded by the framework. Scripts that run outside Next load neither unless they do it themselves, which is where the two files start to diverge.
Should I use Vercel's sensitive environment variables?
For API tokens, yes. A sensitive variable is stored unreadable, so nobody with project access can read the value back afterwards, and build logs redact any value of 32 characters or more. The trade-off is real: you cannot recover it, only replace it, and it is unavailable in the Development environment.

Muhammad Kashif

Founder and editor of Devventa, covering AI coding assistants, Next.js and the modern AI development stack.