Environment variables
Status: 🟩 COMPLETE Last updated: 2026-06-19 Plain-English tagline: Values your app reads from the outside world rather than hard-coding — used for both configuration (what URL is my database at?) and secrets (what’s my API key?).
In plain English
When you write code, you don’t want to bake every value directly into the source. Two reasons:
- The value changes between environments. Your local laptop uses a development database; your live site uses a production database. Same code, different database URLs.
- The value is a secret. API keys, database passwords, service-role tokens. These must NEVER be in your Git history because Git history is forever and often public.
The solution is environment variables — values that live OUTSIDE your code, in the surrounding “environment” your program runs in. The program reads them at startup and uses them however it likes.
In Node.js, environment variables show up as process.env.YOUR_VAR_NAME. In a shell, they’re available as $YOUR_VAR_NAME. On your Mac, the PATH environment variable tells the shell where to find commands; on Vercel, DATABASE_URL tells your app where to find Supabase. Same mechanism, different scope.
The pattern is so universal it has its own short name: env vars, sometimes ENV. Every modern language, framework, and hosting platform supports them in essentially the same way.
Why it matters
Two huge categories of bugs and security incidents trace back to environment variable mishandling:
-
“It works on my machine.” The classic. Your local app uses
DATABASE_URL=postgresql://localhost..., the deployed app usesDATABASE_URL=postgresql://supabase.... If you accidentally hard-code the local URL, deploys break. Env vars are how you cleanly separate “code that always behaves the same way” from “config that varies.” -
Leaked secrets. Hard-coding an API key into a file, committing it, pushing it to GitHub, then realizing it’s been scraped within minutes by bots. Once a secret is in Git history, rotating it is the only safe response. Env vars + a
.gitignored.env.localfile prevent this cleanly.
There’s also the operational benefit: you can rotate a key (change SUPABASE_SERVICE_ROLE_KEY in the Vercel dashboard) without changing a single line of code. Configuration becomes a runtime decision, not a code change.
How env vars get into your app
Three layers, increasingly specific:
1. The shell
When you open a terminal, you’re inside a shell process that has environment variables of its own. PATH, HOME, USER are always set. You can add more:
# Bash / Zsh
export API_KEY="abc123"
node my-app.js # node sees process.env.API_KEY === "abc123"# PowerShell
$env:API_KEY = "abc123"
node my-app.jsThis is fine for ad-hoc testing but tedious to repeat. Hence…
2. .env files
Place a .env (or .env.local) file in your project root:
DATABASE_URL=postgresql://localhost:5432/mydb
SUPABASE_URL=https://abc123.supabase.co
SUPABASE_ANON_KEY=eyJh...
SUPABASE_SERVICE_ROLE_KEY=eyJh...
NEXT_PUBLIC_SITE_URL=http://localhost:3000
Most modern frameworks (Next.js, Vite, Astro, Remix, Rails, Django via python-decouple, etc.) automatically read these files at startup and inject them into process.env. You commit a .env.example (a template with empty values) to share the names; you .gitignore the actual .env* files.
3. The hosting platform
When deployed, there’s no .env file — instead, the hosting platform (Vercel, Netlify, Cloudflare, etc.) lets you set env vars in its dashboard. They get injected into the runtime environment when your app starts. You set them once per environment (production, preview, development) and they persist across deployments.
A concrete example: the four .env files of a Next.js project
Next.js has a layered loading system that catches most people off guard. The files Next.js looks for, in order of precedence:
| File | When loaded | Committed? |
|---|---|---|
.env.production.local | Production builds, local-only override | NO (gitignored) |
.env.development.local | Dev mode, local-only override | NO (gitignored) |
.env.local | All environments, local-only override | NO (gitignored) |
.env.production | Production builds, project-wide | Sometimes (no secrets) |
.env.development | Dev mode, project-wide | Sometimes (no secrets) |
.env | All environments, project-wide | Sometimes (no secrets) |
The rule of thumb: Put real secrets only in *.local files (which are gitignored). Use non-.local files for innocent defaults that are safe to share.
For a typical project, you only need two:
.env.local— contains all secrets, gitignored, lives on your laptop.env.example— empty template with key names, committed to the repo as a guide for collaborators
The NEXT_PUBLIC_ rule (Next.js / Vite have equivalents)
This is the env-var trap that bites everyone at least once.
In Next.js, environment variables are server-only by default. They are NOT available in client-side code. This is correct — secrets shouldn’t ever reach the browser.
If you want a variable to ALSO be available in the browser, you must prefix it with NEXT_PUBLIC_:
SUPABASE_SERVICE_ROLE_KEY=eyJh... # server only, never reaches browser
NEXT_PUBLIC_SUPABASE_URL=https://... # safe, sent to browser
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJh... # safe, sent to browserThe NEXT_PUBLIC_ prefix tells Next.js: “this value can be inlined into the JavaScript bundle, where the browser will see it.” Anything WITHOUT this prefix is stripped before client code is built.
The catastrophic mistake: prefixing a real secret with NEXT_PUBLIC_. NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY would bake your service-role key (which bypasses all database security) into the browser bundle. Anyone visiting your site could view-source and grab it.
Other frameworks use the same pattern with different prefixes:
| Framework | Public prefix |
|---|---|
| Next.js | NEXT_PUBLIC_ |
| Vite (React, Vue, Svelte) | VITE_ |
| Create React App (legacy) | REACT_APP_ |
| Remix | by convention (no enforced prefix; you choose what to expose) |
| Astro | PUBLIC_ |
| Nuxt | NUXT_PUBLIC_ |
| Expo (React Native) | EXPO_PUBLIC_ |
Different prefixes, identical mechanism: anything with the public prefix ends up in the client bundle; anything without it doesn’t.
How env vars get baked in at build time vs read at runtime
A subtle but important distinction:
- Build-time env vars are read once when the app is being built into deployable files. The value is baked into the output. Changing the env var requires rebuilding the app. (
NEXT_PUBLIC_*is the classic example.) - Runtime env vars are read every time the running server process starts (or even per-request, in some setups). You can change them in your hosting dashboard and they take effect immediately on the next request — without rebuilding.
For Next.js:
NEXT_PUBLIC_*vars → build time. Changing them requires redeploying.- Non-public vars used in server code → runtime (in the Node.js sense — but practically, since each deploy is a fresh build, the effect is the same: change them in Vercel and redeploy).
For pure server-rendered apps (Rails, Django, Express): env vars are runtime — restart the process and changes apply.
This is why Vercel’s docs say “redeploy after changing an env var” — they want consistency regardless of which kind of var you’re changing.
Vercel’s env-var UI
For the Bible Quest project:
- Vercel dashboard → project → Settings → Environment Variables
- Click “Add New”
- Enter the key (e.g.
SUPABASE_SERVICE_ROLE_KEY) - Enter the value (paste the secret)
- Check which environments it applies to: Production, Preview, Development
- (Optional) Mark as “Sensitive” so the value can’t be read back from the dashboard
- Save
To pull these values down to your laptop for local development:
vercel env pull .env.localThis writes the production values into your local .env.local (which is gitignored). Now your laptop dev environment is in sync with production.
Secrets management — env vars are step 1, not the destination
Env vars are the universal interface for “give my app a value at runtime.” But for serious projects, the env vars themselves are populated FROM a more secure place:
| Source | Use case |
|---|---|
| Hosting dashboard (Vercel, Netlify) | Default for solo / small-team projects |
| AWS Secrets Manager / Parameter Store | AWS-hosted apps; rotation built in |
| HashiCorp Vault | Big enterprise, on-prem, regulated industries |
| Doppler / Infisical / 1Password Secrets Automation | Multi-environment teams that want a single source of truth |
| GitHub Actions Secrets | CI/CD pipelines |
For the kinds of projects George builds, the hosting dashboard is plenty. The pattern only escalates as a team grows or compliance demands it.
See Secrets management for the broader topic.
Common gotchas
-
.envfiles MUST be in.gitignore. Every Next.js / Vite project includes.env*.localin.gitignoreby default. If you create a.env(no.local) yourself, double-check it’s ignored. Once a secret is in Git history, rotate it — don’t just delete the commit. -
The
NEXT_PUBLIC_prefix exposes the value to the world. Treat anyNEXT_PUBLIC_*value as if it’s printed on a billboard. Never use it forSERVICE_ROLE_KEY,STRIPE_SECRET_KEY, or anything that grants real access. -
Env var changes don’t apply until you redeploy. Changing
STRIPE_API_KEYin the Vercel dashboard doesn’t update existing deployments. Go to Deployments → ”…” → Redeploy on the latest deployment. -
No quotes needed in
.envfiles (usually).API_KEY="abc"andAPI_KEY=abcboth work in most loaders. BUT some values genuinely need quotes — values with#(interpreted as comment start),=, or whitespace. When in doubt, quote. -
Multi-line values are tricky. Some
.envparsers don’t support newlines in values. Common workaround: replace newlines with\nand decode in code. For PEM keys, base64-encode the whole thing and decode on load. -
process.env.FOOisundefinedif you forgot to set it — no error. This is a frequent source of “why is thisnull?” debugging. Validate critical env vars at app startup; throw a loud error if any are missing. -
TypeScript doesn’t know about your env vars by default.
process.env.MY_VARis typed asstring | undefined. You can declare types in aenv.d.tsfile or use a runtime validator likezod+@t3-oss/env-nextjs. -
Casing matters.
API_KEYandapi_keyare different env vars. Convention isUPPER_SNAKE_CASE, but enforce whatever convention you pick. -
vercel env pulloverwrites your.env.local. If you have local-only values in.env.local, back them up first or use a separate file. -
Different envs need different secrets. Production should NEVER share API keys with preview, and preview shouldn’t share with development. Especially: never use production Stripe keys (
sk_live_...) in preview deployments — anyone with the preview URL could trigger real charges. -
CI/CD has its own env vars. GitHub Actions secrets are separate from Vercel env vars. If you have a build step that runs in CI and a runtime that runs in Vercel, you may need to set the same secret in both places.
-
Some platforms strip leading/trailing whitespace, some don’t. When pasting a multi-line key into a dashboard, you can accidentally include a trailing newline. If your app can’t authenticate with a key that “looks right,” check for stray whitespace.
-
Env vars are visible in the running process. Anyone with shell access to the server can read them via
printenv. They’re “secret from the public,” not “secret from people with access to your server.” Treat hosting access as a sensitive credential. -
Cloud functions sometimes have size limits. Vercel has a 4KB limit per env var and a total limit on env-var size per deployment. Don’t try to stuff huge JSON blobs into env vars; use a secret manager or a file.
-
Loading
.envfiles is a feature of the framework, not Node.js. Plainnode my-app.jsdoes NOT read.env. Only frameworks (Next.js, Vite) or explicit loaders (dotenvpackage) do. If you’re scripting Node outside a framework, install and requiredotenv. -
.env.exampleis your team’s documentation. Keep it updated with every new env var. List the keys, leave the values blank or use placeholders. New collaborators copy it to.env.localand fill in real values.
A starter .env.example for a Next.js + Supabase app
# Supabase
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
SUPABASE_SERVICE_ROLE_KEY=
# Auth
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=
# Public site URL (used in metadata, emails, etc.)
NEXT_PUBLIC_SITE_URL=http://localhost:3000
# OPTIONAL: Stripe (only if billing is enabled)
STRIPE_SECRET_KEY=
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
STRIPE_WEBHOOK_SECRET=
# OPTIONAL: AI / LLM keys
ANTHROPIC_API_KEY=Commit this file. Anyone cloning the repo copies it to .env.local and fills in real values from their own accounts.
See also
- Vercel 🟩 🟦 — the env-var dashboard for production
- What is hosting? đźź©
- Secrets management 🟩 — the broader security context
- Supabase 🟩 🟦 — the canonical source of env-var-managed credentials in this stack
- Next.js 🟩 🟦 — the
NEXT_PUBLIC_rule lives here - CD 🟥 — env vars in pipelines are separate
- The terminal 🟩 — where shell env vars are set
- Glossary: Environment variable, Secret, .env file
Sources
- Next.js — Environment Variables — official, authoritative
- Vercel — Environment Variables — hosting-specific
- The Twelve-Factor App — Config — the classic essay on why config belongs in env vars
- dotenv on npm — the package every framework’s
.envloader is based on - Supabase — Auth helpers env vars