Skip to main content
ASoc
Tutorial

Next.js env: Which File Wins, and Why Your Script Reads None

Measured on Next 16.2.9: the three .env files dev loads, which one wins, the build-time freeze, and the 500 every request throws when one is missing.

The ASoc Team11 min read

Next.js loads your .env files itself, in a fixed order, and the first file to define a name wins. In development that order is .env.local, then .env.development, then .env — the dev server prints the list on boot. Nothing else loads them: your own Node and tsx scripts start with an empty process.env.

The short answer

There are three different moments an environment variable can be read, and almost every "it works locally, not in production" bug is a mismatch between them:

  1. Build time. NEXT_PUBLIC_* values are inlined into the JavaScript bundle, and anything read in next.config.ts is baked into the config. Both freeze at the moment the build runs.
  2. Request time. Server-only values are read live, on the server, from the process environment — which on a host like Vercel is the dashboard, not a file.
  3. Never. A standalone script you run with node or tsx does not load .env.local at all, because the loader belongs to the Next CLI.

What this repo measured, today

On Next 16.2.9, with four probe files in the project root, npm run dev printed:

▲ Next.js 16.2.9 (Turbopack)
- Local:         http://localhost:3000
- Environments: .env.local, .env.development, .env
✓ Ready in 466ms

Two things in one line. .env.production is absent — it is not loaded in development, however tempting the filename is. And the three that are loaded are listed in precedence order, highest first.

That order is testable without a test harness, because this project already renders an environment variable into a response header. next.config.ts derives the Content-Security-Policy's connect-src from the Supabase URL:

const supabaseOrigin = (() => {
  try {
    return new URL(process.env.NEXT_PUBLIC_SUPABASE_URL!).origin;
  } catch {
    return "https:";
  }
})();

Setting NEXT_PUBLIC_SUPABASE_URL to a different value in .env.local and in .env.development, then reading the header off the dev server, answers the precedence question directly:

connect-src 'self' https://local-wins.supabase.co

.env.local won. The same experiment is worth more than any blog post's table, because it takes one curl and it tells you about your Next version rather than the one someone wrote about two years ago.

The file table

FileLoaded in devLoaded in next build / startCommit it?
.env.localYes — highest precedenceYesNo — this is where real secrets go
.env.developmentYesNoYes, for non-secret defaults
.env.productionNoYesYes, for non-secret defaults
.envYes — lowest precedenceYesYes, for non-secret defaults
.env.exampleNever — it is documentationNeverYes, always

This repo's .gitignore is three lines that encode exactly that:

# env files (can opt-in for committing if needed)
.env*
!.env.example

Ignore every .env*, then un-ignore the example. The example file is the only one in version control, and it is worth writing properly — ours carries the comment that explains why a LemonSqueezy variant has two different identifiers, because getting that wrong "fails silently in the worst direction: the UUID in LS_VARIANT_ID_* means checkout works and every webhook is ignored — buyer charged, nothing granted."

That is the real job of .env.example. Not the list of names — the list of names is discoverable by grepping for process.env. The job is the warnings that only the person who debugged it knows.

The freeze: what "inlined at build time" costs you

NEXT_PUBLIC_ is usually explained as "this one is public." That is true and it is the less important half. The important half is that the value is substituted into the bundle as a string literal when the build runs, so it is a property of the build, not of the running server.

The consequence that bites: changing a NEXT_PUBLIC_* value in your host's dashboard does nothing until you rebuild. No restart, no cache purge, no redeploy of the same artifact will do it — the old string is already compiled into the JavaScript you are serving.

The same freeze applies, more quietly, to next.config.ts. The supabaseOrigin snippet above runs when the config is evaluated, and its output ends up in a static response header. Deploy with that variable unset and the catch returns the string https:, so the policy still parses and the site still works — with a materially weaker connect-src than the one you reviewed. A fallback that keeps the site up is the right call there, but it is the kind of silent downgrade worth a deploy-gate check rather than trust.

Rules of thumb that follow:

  • A value that must change without a rebuild cannot be NEXT_PUBLIC_. Read it on the server and pass it down as a prop, or fetch it from an endpoint.
  • A value read in next.config.ts is build-machine configuration. Treat your CI's environment as part of your deployment config, because it is.

What this codebase actually reads

Grepping the application code — src/app, src/lib, src/components, src/data, src/proxy.ts — finds 36 reads of 16 distinct names. Three of them carry the public prefix:

NameReadsWhere it runs
NEXT_PUBLIC_SITE_URL10Build time — absolute URLs in metadata, sitemap, JSON-LD
NEXT_PUBLIC_SUPABASE_URL5Both — browser client, server client, and the CSP in next.config.ts
NEXT_PUBLIC_SUPABASE_ANON_KEY4Both — paired with the URL everywhere a client is created
RESEND_API_KEY4Request time, server only
LEMONSQUEEZY_STORE_SUBDOMAIN2Request time, server only
11 more, 1 read each11Request time, server only — webhook secret, service-role key, the six variant ids

The shape is the useful part: the three public names are read the most, because an absolute site URL is needed by every metadata surface, while the secrets are read exactly once each, in the one module that owns them. That is not an accident of this codebase — it is what a healthy env surface looks like. A secret read in five places is five places to audit.

For the other half of this — which of those values can reach the browser, and how to prove it from a build output rather than from the prefix rule — see which environment variables reach the browser.

The failure mode worth knowing about

src/proxy.ts (Next 16's renamed middleware) refreshes the Supabase session on nearly every request, and it reads two variables with a non-null assertion:

export async function proxy(request: NextRequest) {
  let response = NextResponse.next({ request });

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    { /* cookie plumbing */ },
  );

  await supabase.auth.getClaims();
  return response;
}

The ! is a TypeScript assertion. It silences the compiler and promises nothing at runtime. Deleting both variables and booting the dev server reproduces the result in one request:

Error: Your project's URL and Key are required to create a Supabase client!
    at createServerClient (.next/dev/server/chunks/node_modules_0nn20k4._.js:14678:15)
    at proxy (.next/dev/server/chunks/[root-of-the-server]__218n7ix._.js:91:218)

Every request, including the static marketing pages that need no session at all, because the proxy's matcher runs before routing does. One missing variable, whole site down — which is why this repo's CLAUDE.md carries a standing warning not to promote a production build until those two variables are set in the hosting project.

Two defences are worth copying:

  • Narrow the matcher. Ours already excludes static assets, image files, sitemap.xml, robots.txt and the search-console verification file. Fewer requests through the proxy is fewer requests that can fail in it.
  • Fail at build, not at request. Validate the names you cannot run without at the top of a module that the build evaluates. A build that refuses to finish is an incident that never reaches a user.

Keeping a secret out of the client bundle

The prefix rule is a convention, and conventions get broken by a tired import. This repo makes the boundary a build error instead, in src/lib/supabase/admin.ts:

import "server-only";
import { createClient as createSbClient } from "@supabase/supabase-js";

export function createAdminClient() {
  return createSbClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.SUPABASE_SERVICE_ROLE_KEY!,
    { auth: { persistSession: false, autoRefreshToken: false } },
  );
}

import "server-only" is a package whose only job is to throw if it is pulled into a client bundle. Import this module from a "use client" component and next build fails by name. The service-role key bypasses row-level security entirely, so "we have a rule about this" is not a sufficient control; a failing build is.

The script that reads none of them

This is the gap that costs the most time, because nothing about it looks like an environment problem.

The .env loader is part of the Next CLI. Run a release script with tsx and it starts with whatever your shell exports — which does not include .env.local. The symptom is a script that insists a variable you can see in the file is undefined.

The fix in this repo is to load the file explicitly for anything run outside Next, which is why every release command is written this way:

npx dotenv -e .env.local -- npx tsx scripts/release/release-template.ts <slug>

dotenv -e .env.local -- loads the file, then execs the real command with those values in its environment. The alternative — exporting them in your shell — works until you forget, at which point a script that uploads release zips runs against the wrong project. Making the loader part of the documented command removes the chance to forget.

Troubleshooting

SymptomCauseFix
undefined in a node/tsx script, value clearly in .env.localOnly the Next CLI loads .env*Run it via npx dotenv -e .env.local -- …, or export the variable
Changed a NEXT_PUBLIC_* on the host, nothing changedThe old value is compiled into the shipped bundleTrigger a rebuild; a restart or redeploy of the same artifact will not do it
Works in npm run dev, missing in productionThe value lives in .env.development, which next build does not loadMove it to .env, or set it in the hosting project
.env.production ignored while developingNot loaded in development, by designUse .env.development or .env.local locally
Secret visible in the browser bundleIt was given the NEXT_PUBLIC_ prefixRename it, rotate it, and add import "server-only" to the module that reads it
Every route 500s after a deployA variable read with ! in the proxy is unsetSet it in the hosting project; validate the name at build time
Value correct in one file, stale in anotherA higher-precedence file also defines itRemember .env.local beats .env.development beats .env; grep all of them for the name
A new teammate's checkout fails immediatelyThe name exists only in your untracked .env.localAdd it to .env.example with a comment on where to get it

Frequently asked questions

Which .env file wins? In development, .env.local, then .env.development, then .env — first match wins. In a production build, .env.local, then .env.production, then .env. Real values set in the actual process environment (your host's dashboard, or your shell) beat all of them.

Do I need NEXT_PUBLIC_ for a variable used in a Server Component? No, and you should not add it. Server Components run on the server and read process.env directly. The prefix is what publishes a value into the browser bundle; adding it "just so it works" is how secrets ship.

Should I commit .env.production? Only if everything in it is public. It is loaded by the production build, so it is a convenient place for non-secret defaults such as a site URL — and a dangerous place for keys. Secrets belong in your host's environment settings, where they are not in git history.

Why would a variable be read in next.config.ts at all? Because some configuration is a build artifact rather than a runtime decision — response headers, redirects, image domains. This project derives the CSP's connect-src from the Supabase URL so the policy is correct per environment without maintaining three copies of it. The cost is that the build machine's environment has to be right.

Templates in this post

ASoc Mind (an AI marketing-solutions site), ASoc Momentum (an AI-consulting agency site with a contact funnel) and ASoc Neuron (a neural-networks platform site with 3-tier pricing) each ship a committed .env.example and read their site URL exactly the way described above, so the first deploy needs one value set rather than a hunt through the source.

Browse the full sets: Next.js landing page templates, Tailwind landing page templates. For the deployment side of the same question, see Next.js deployment; for what else belongs in next.config.ts, see the Next.js config file.

Keep reading

Tutorial11 min read

Next.js Error Boundaries: One File, and a Digest Nothing Read

One error.tsx covering 27 page files, no global-error.tsx, and a digest the boundary declared but discarded — the audit, and both fixes that shipped with this post.

Read more
Tutorial10 min read

Next.js Error Monitoring: 41 console.error Calls, Zero APM

No Sentry, no instrumentation.ts — 41 structured console.error calls, an ALERT-marker convention, and the error-hygiene test that keeps server detail out of the browser.

Read more