NEXT_PUBLIC_ and friends: the framework conventions that publish your config
The definitive guide to public-by-prefix environment conventions across major frameworks — the mechanism, the promise, and the accidents.
NEXT_PUBLIC_ and friends: the framework conventions that publish your config
In a frontend project, naming a variable is a publication decision. Here is exactly what each framework's public-env convention does mechanically, what the prefix promises and doesn't, and the accident patterns worth guarding against.
Every modern frontend framework solves the same problem the same way: browsers can't read server environments, so certain variables must be delivered into client code, and a naming convention decides which. Learn the conventions precisely and they're excellent tools. Mislearn them — usually by treating the prefix as decoration rather than as a publication instruction — and the framework faithfully publishes whatever value sits behind the name.
This is the definitive mechanical tour: substitution semantics per framework, compile-time versus runtime-public delivery, the edge cases that surprise even experienced developers, and the rule set that keeps the conventions useful. The downstream consequences for bundles specifically live in the browser-bundle guide; the workflow that feeds secrets into these variables accidentally is traced in how keys migrate.
What the prefix actually promises
Read any framework's documentation closely and the claim is narrower than folklore remembers. Next.js says NEXT_PUBLIC_ variables are available in browser code because their values are inlined into the JS bundles sent to the client (environment variables). Vite says VITE_-prefixed variables are statically replaced in client-served code (env and mode). Create React App, Expo, and Astro say equivalent things about REACT_APP_*, EXPO_PUBLIC_*, and PUBLIC_*.
The promise decomposes into three clauses:
- Publication: the value will be transmitted to every user. This is unconditional and by design.
- Intent transfer: by using the prefix, you assert the value is safe for publication. The bundler trusts the assertion completely.
- Nothing else: no validation, no format awareness, no distinction between a public map tile key and a payment credential. The prefix is an instruction to copy, not an assessment of suitability.
That third clause is where incidents originate. The convention has no opinion about contents; it publishes strings the way a printing press publishes pages. A variable named NEXT_PUBLIC_STRIPE_KEY holding a live secret key ships exactly as efficiently as one holding a publishable key — and the name itself does nothing, being just another identifier minified away.
Compile-time substitution: the precise mechanics
For the statically-replacing frameworks, understanding exact behavior prevents both false confidence and false alarm:
Replacement is textual and expression-scoped. When Next.js's compiler encounters process.env.NEXT_PUBLIC_API_URL in client-included code, it substitutes the literal string from the build environment. Vite performs the same replacement on import.meta.env.VITE_API_URL. Webpack's DefinePlugin (underlying CRA) operates identically. After substitution, the compiled chunk contains the value as a plain string literal — greppable, minification-surviving, present forever in that build.
Dynamic access breaks the guarantee silently. import.meta.env[key] or destructured iteration over the env object does not get replaced — the compiler matches literal expressions. Depending on framework, such code yields undefined at runtime (values absent from any runtime source) or, worse in some setups, broader exposure than intended. Either way, the intuition "it's env-backed" fails quietly; test what actually compiles rather than reasoning from the source.
Values freeze at build time. Substitution happens once, when build runs. Updating the variable in hosting settings afterward changes nothing until the next build-and-deploy cycle — including nothing about already-deployed chunks still cached on CDNs and in service workers.
Server code remains genuinely private. The same variable unprefixed stays server-only: Next.js server components and route handlers read process.env.STRIPE_SECRET_KEY without any client transmission. The prefix — not the file, not the module location — draws the line, which is precisely why moving a line of code between server and client contexts changes everything while looking cosmetic in diffs (the migration loop).
Runtime-public delivery: the other publication channel
Not every framework bakes values in. Several deliver public config to the client at page load, which changes caching behavior but not secrecy:
- Nuxt serializes public runtime config into the rendered payload; values ship in HTML/payload scripts rather than static chunks (runtime config).
- Remix loaders return server-computed data that serializes into the page; anything placed in loader output reaches the browser by design.
- React Server Components / Next.js App Router pass serialized props from server to client components — values crossing that boundary transmit at request time.
The operational difference matters for rotation speed (runtime channels pick up new values on redeploy without rebuilding chunks) and for scanning (the value may sit in HTML payloads rather than .js files — scanners must read both). The secrecy conclusion is identical: if it crosses to the client through either channel, it's public.
The framework reference
Consolidated behavior, with each row's authoritative documentation:
| Framework | Client-visible form | Delivery | Reference |
|---|---|---|---|
| Next.js | process.env.NEXT_PUBLIC_* | Compile-time inline into bundles | docs |
| Vite | import.meta.env.VITE_* | Compile-time static replacement | docs |
| Create React App | process.env.REACT_APP_* | Compile-time via webpack DefinePlugin | docs |
| Expo | process.env.EXPO_PUBLIC_* | Inline into app bundle at build | docs |
| Astro | import.meta.env.PUBLIC_* | Compile-time replacement | docs |
| Gatsby | GATSBY_* via process.env | Compile-time (webpack DefinePlugin) | docs |
| Nuxt | useRuntimeConfig().public | Runtime payload serialization | docs |
| SvelteKit | $env/static/public, $env/dynamic/public | Import-path based: public modules inline or fetch public values; private modules stay server-side | docs |
SvelteKit deserves its note: instead of prefixes it uses module choice — importing $env/static/private anywhere reachable from client code fails the build, making the wrong choice loud rather than silent. It's the strongest ergonomic design in the list, and the reason "which module did this import come from?" replaces "what prefix?" in its audits.
How accidents happen anyway
With the mechanics clear, the recurring failure patterns predict themselves:
- The debugging promotion: during a broken build, someone renames the failing variable with the magic prefix to unblock shipping, meaning to revisit. The revisit has no ticket. The pattern generalizes: any friction that the prefix resolves becomes a temptation whenever deadlines compress.
- Rename-all tooling: project-wide search-replace intended for a different variable catches the secret-bearing one; the diff shows two lines changed, both plausible. Bulk operations don't evaluate contents.
- Convention inheritance by agents: AI assistants observe existing prefixed usage and reproduce it for new features — locally consistent, globally wrong. Once one prefixed secret exists, the convention teaches itself to every subsequent generation (traced step by step here).
- Platform-injection surprises: hosting platforms inject configured variables into builds; adding a client-facing prefix in the dashboard "to see it in the preview" publishes the value on the next deploy. The UI made it convenient; the convention made it public.
- Template contamination: starter kits and examples shipping prefixed demo values train developers that prefixed names hold provider credentials routinely.
All five share a signature: the publishing act is invisible at the point of decision. Nobody sees bytes enter a bundle; everyone sees tests pass. Which motivates the detection side.
Framework-specific gotchas worth testing once each, because they surprise even experienced operators:
- Vite's
define()footgun. Projects can define arbitrary compile-time constants beyond env variables — including secret-shaped ones added "temporarily" during build debugging. Grepvite.config.*for define entries during audits; the config file is tracked, but its values may come from environment at build time. - Astro island props. Server-rendered components passing values into hydrated islands serialize those props into HTML — a runtime-publication channel distinct from
PUBLIC_substitution (the payload mechanism). - Expo update manifests. OTA update manifests carry environment-derived values alongside bundles; auditing means checking manifest payloads, not only chunk files.
- Middleware and edge runtimes. Values read in edge/middleware contexts follow their own bundling rules per framework version — verify rather than assume server-side privacy, since edge targets sometimes inline differently than Node targets.
- Monorepo cross-imports. A shared package imported by both server and client graphs gets compiled twice with different substitution — safe in one artifact, published in the other. The import graph, not file location, decides.
Each gotcha responds to the same discipline: when in doubt, build and inspect. Substitution behavior is deterministic per toolchain version, which makes ten minutes of empirical testing worth more than any amount of documentation reading — and makes CI-level built-output scans (the boundary guard) the only check that stays correct across framework upgrades.
Rules worth encoding
Whether enforced by lint config, CI checks, or scanner rules (the rules reference), four checks cover the accident space:
- Allowlist expected-public variables: maintain the explicit set of prefixed names that may exist (
NEXT_PUBLIC_API_URL,NEXT_PUBLIC_SUPABASE_ANON_KEY…); flag additions as review-required events rather than silent successes. - Detect secret formats inside public values: at build time, assert that no
NEXT_PUBLIC_*/VITE_*/etc. value matches elevated-credential formats —sk_live_,sk-proj-,sb_secret_,AKIA…. A value that fails belongs behind a server route, full stop. - Scan built output, not just inputs: substitution happens after linting runs; the bundle is ground truth. Post-build and post-deploy scans catch the cases input-side rules miss — including values injected by platforms above your config layer.
- Monitor continuously: deploys re-run substitution with current environments; drift returns between manual checks unless something re-checks automatically (scanning versus monitoring).
These encode the actual contract: prefixes publish, so publication must be observed and audited like any other release act.
Test substitution behavior yourself
Framework documentation describes replacement; running it settles questions permanently. esbuild exposes the same primitive in five minutes:
mkdir sub-demo && cd sub-demo && npm init -y && npm i -D esbuild
// app.js
const direct = process.env.APP_SECRET;
const viaKey = import.meta.env?.VITE_THING;
const dynamic = import.meta.env[dynamicKeyName]; // computed access
console.log(!!direct, !!viaKey, !!dynamic);
// build.mjs
import * as esbuild from "esbuild";
await esbuild.build({
entryPoints: ["app.js"],
bundle: true,
define: { "process.env.APP_SECRET": '"APP_SECRET_FAKE0000000000000000"' },
outfile: "out.js",
});
node build.mjs && cat out.js
The output demonstrates all three behaviors at once: APP_SECRET replaced by its literal string (synthetic here); import.meta.env untouched without Vite's transform pipeline; and — the instructive case — computed access left as code, resolving to undefined at runtime rather than leaking or working. Dynamic access neither publishes values nor preserves functionality; it silently breaks features while looking identical in source review. Every framework's edge cases reduce to variations on these three outcomes, which is why "test the build, don't trust the intuition" heads the practical advice list.
Run the same experiment against your actual framework's CLI afterward: add a marked variable, reference it from a client module three ways, inspect dist/. Ten minutes yields permanent calibration about where your stack's publication line sits — knowledge that transfers directly to reviewing other people's changes.
Un-publishing a value that already shipped
Discovering a published variable starts a sequence where ordering matters more than speed alone (full incident context):
- Rotate the underlying credential first. Bundle contents may persist in CDNs, browser caches, and service-worker precaches long after redeployment — removing the value from source changes nothing for copies already collected.
- Move consumption server-side, not merely unprefixed: renaming the variable back keeps the call client-side, reintroducing the original build failure and tempting a second prefix. Route handlers, server actions, or edge functions hold the value where substitution can't reach.
- Rebuild clean and deploy, then verify the served surface directly — chunk-level inspection beats deployment confidence.
- Purge delivery layers: CDN cache invalidation for changed paths, and a service-worker version bump where workers precache assets, since stale workers serve stale bundles indefinitely.
- Add the guardrail: whatever allowed the prefix onto a secret-tier name gets structural correction — allowlist enforcement, the CI boundary check, or import-boundary rules — so the class closes rather than the instance (rules worth encoding).
Step one before step two, always: teams that reverse the order spend the interval between fix-deploy and rotation believing they're safe while cached bundles still serve the live credential. The full bundle-persistence mechanics — including why "I redeployed" is weaker than it sounds — live in the browser-bundle guide.
When documentation runs out: read the toolchain
Prefix behavior is implemented in bundler code, and edge cases cluster at version boundaries — framework upgrades change substitution details without announcing it. Two habits keep you current:
- Locate the implementation once. The prefix strings live in your installed toolchain — searching
node_modulesforNEXT_PUBLIC_or the Vite prefix constant finds the exact plugin performing replacement, and reading fifty lines of it settles every "does X substitute?" question for your pinned version. - Re-run the empirical test on upgrades. The substitution demo belongs in the upgrade checklist alongside dependency diffs: rebuild the marked module, diff the output, confirm nothing new publishes. Toolchains fix and break these behaviors quietly; a ten-minute check converts release notes from folklore into verified fact.
Teams with many services centralize this as a compatibility note per framework version — which behaviors were tested, on what date, against what output — so upgrades inherit evidence instead of re-deriving it. It's the same instinct as pinning dependencies, applied to behavior rather than versions.
Import-boundary guards turn conventions into compile-time failures where frameworks support them. Next.js ships the pattern as a package: importing server-only from a module makes any client-graph inclusion fail the build with a loud error, converting silent publication attempts into red screens. Equivalent enforcement arrives in Vite ecosystems via environment-type declarations and lint rules rejecting prefixed reads outside approved modules. One line per server module, reviewed like any other dependency — and the accidental-import class of accident disappears at build time, before bundlers ever decide what to inline (the boundary guard pattern).
FAQ
Is NEXT_PUBLIC_ unsafe? Should we avoid it entirely? It's exactly as safe as the values behind it. Publishable identifiers belong behind the prefix — that's its purpose. Elevated credentials never do. The failure isn't the convention; it's treating membership in the convention as unreviewed.
Do unprefixed variables ever leak into client code? In correctly functioning builds, unprefixed references in client contexts resolve empty (and may break features visibly). The dangerous direction is the opposite: prefixed references pulling server-intended values outward. Verify by inspecting bundles, not by trusting naming intentions.
We rotate the underlying key regularly. Doesn't that bound the damage? Rotation bounds duration, not disclosure: between rotations the key serves every visitor, and automated collectors don't wait for schedules. Rotation remains essential after discovery (the playbook) — as cleanup, not prevention.
How do I audit an inherited codebase quickly? Two passes: list every prefixed variable in the repo against the expected-public allowlist, then scan the deployed bundle for credential formats — the latter catches everything the former's assumptions miss. The free scan completes the second pass in minutes.
Prefixes publish. Audit what got published: run KeyDrift's free scan on your deployed site — no signup, results in minutes.