Environment variables missing in production: a checklist for vibe coders
"It works on my machine" is often an environment variable problem. Locally you have a .env file. In the Lovable, Bolt or Replit preview, the platform injects the values for you. On Vercel, Netlify or your own server, there's nothing until you add it. The symptoms vary: a blank page, undefined in API URLs, supabaseUrl is required, a Stripe or OpenAI call returning 401, or a build that fails with Missing required environment variable.
This checklist works for any stack an AI tool is likely to generate: Vite/React (Lovable, Bolt), Next.js (v0, Cursor) and Node backends (Replit). Go through it from top to bottom.
1. Make the full list of what your app needs
Don't trust your memory, or the AI's. Search the code:
# Vite / React
grep -rhoE "import\.meta\.env\.[A-Z0-9_]+" src | sort -u
# Next.js / Node
grep -rhoE "process\.env\.[A-Z0-9_]+" --include=*.{ts,tsx,js,jsx,mjs} . \
--exclude-dir=node_modules | sort -u
Compare the result with your local .env and with what's set on the host. Write it down in a committed .env.example with placeholder values, never real ones:
# .env.example: names only, safe to commit VITE_SUPABASE_URL= VITE_SUPABASE_ANON_KEY= STRIPE_SECRET_KEY= OPENAI_API_KEY=
This file is the contract. Anyone (or any AI tool) setting up the project knows exactly what has to exist.
2. Get the prefix right: browser vs server
Frameworks deliberately hide variables from the browser unless you opt in with a prefix:
- Vite: only
VITE_*reachesimport.meta.envin client code. - Next.js: only
NEXT_PUBLIC_*is available in the browser. Everything else is server-only (Server Components, Route Handlers, Server Actions). - Create React App (older projects):
REACT_APP_*. - Expo:
EXPO_PUBLIC_*.
The two classic mistakes go in opposite directions. First, a client component reads process.env.SUPABASE_URL (no prefix) and gets undefined. Second, a secret gets renamed to NEXT_PUBLIC_OPENAI_API_KEY to "make it work", and now your OpenAI key is in a public JavaScript file anyone can download.
Rule of thumb: if a key can spend money or bypass security (OpenAI, Stripe secret, Supabase service_role, Resend, database URL), it must not have a public prefix. Call it from a server route or edge function instead.
3. Remember public variables are baked in at build time
Vite and Next.js replace VITE_* / NEXT_PUBLIC_* references with literal values while they build. That has two consequences:
- Adding or changing a variable in the dashboard does nothing until you redeploy. On Vercel, a redeploy that reuses the build cache can still keep the old value, so redeploy without the cache if it doesn't change.
- Docker builds need values as build arguments, not just runtime env. If your Dockerfile runs
npm run buildwithout them, the bundle containsundefined.
# Dockerfile (Vite app) ARG VITE_SUPABASE_URL ARG VITE_SUPABASE_ANON_KEY ENV VITE_SUPABASE_URL=$VITE_SUPABASE_URL VITE_SUPABASE_ANON_KEY=$VITE_SUPABASE_ANON_KEY RUN npm run build
Server-only variables (no prefix) are read at runtime, so they need to exist where the function runs. A redeploy still makes sure new values are picked up.
4. Set them for the right environment
Vercel has Production, Preview and Development scopes. Netlify has deploy contexts. Setting a variable for Preview only is a common reason "the preview link works but the real domain doesn't", and vice versa. Vercel's CLI shows the differences quickly:
vercel env ls production vercel env ls preview vercel env pull .env.production.local --environment=production # inspect what prod gets
Also check where the build runs from. In a monorepo, if Vercel's Root Directory is apps/web, a .env at the repo root isn't read. Next.js and Vite look for .env files in the project root, not inside src/.
5. Watch for copy-paste damage
Values that are "set" but wrong cause the most confusing errors:
- Quotes pasted into a dashboard field.
"https://x.supabase.co", with the quotes, becomes part of the value. - Trailing spaces or newlines, especially in long JWT-style keys. Requests fail with
Invalid API keyor 401. - Keys from the wrong project, such as a Supabase URL from project A with the anon key from project B. AI platforms sometimes spin up a new project when you reconnect.
- Test vs live keys, such as Stripe
sk_test_in production, or a webhook secret from a different endpoint.
6. Fail loudly, at startup
Missing variables should break the build with a clear message, not show up later as a white screen. A tiny validation module does this. With zod, which many AI-generated projects already have:
// src/env.ts (Vite)
import { z } from "zod";
const schema = z.object({
VITE_SUPABASE_URL: z.string().url(),
VITE_SUPABASE_ANON_KEY: z.string().min(20),
});
export const env = schema.parse(import.meta.env);
// env.server.ts (Next.js, server only)
import "server-only";
import { z } from "zod";
export const env = z.object({
DATABASE_URL: z.string().url(),
STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
}).parse(process.env);
Import env instead of reading import.meta.env / process.env directly. If something is missing, the error names the exact variable.
7. Don't forget the secrets that live elsewhere
Not every secret belongs to your host:
- Supabase Edge Functions have their own secrets:
supabase secrets set OPENAI_API_KEY=..., or Dashboard → Edge Functions → Secrets. Your Vercel variables don't reach them. - GitHub Actions need repository secrets, passed explicitly in
env:for build steps. - Replit uses the Secrets tool. A deployed Replit app has its own deployment secrets, which may be separate from the workspace.
- Third-party dashboards also need the new domain: OAuth redirect URLs, Stripe webhook endpoints, allowed origins for API keys.
8. If a secret leaked, rotate it
If .env was ever committed to a public repo, or a secret key had a public prefix, assume it's compromised. Deleting the file isn't enough, because it stays in git history and gets scraped quickly. Rotate the key in the provider's dashboard, update the host, and then add .env* (except .env.example) to .gitignore:
# .gitignore .env .env.* !.env.example
Check with git log --all --full-history -- .env. If that prints anything and the repo is public, rotate.
The one-page checklist
- Every variable the code reads is listed in
.env.example. - Each one is set on the host, in the right scope (Production / Preview).
- Browser variables use the framework prefix; secrets don't.
- Redeployed after every change (without build cache if in doubt).
- No quotes, spaces, or mixed-project / test keys.
- Startup validation names any missing variable.
- Edge function, CI and third-party secrets set separately.
.envnever committed, and leaked keys rotated.
If the variables are all correct and it still fails, the issue is probably redirect URLs or routing. See works in preview, breaks after deploy.
Platform-specific notes
Lovable / Bolt (Vite + Supabase). Preview injects keys. After you connect GitHub and deploy elsewhere, you must set VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY on the host yourself. Some projects hard-code the keys in src/integrations/supabase/client.ts; those don't need env vars for Supabase, but any other API still does.
v0 / Cursor (Next.js on Vercel). Prefer putting secrets in Server Actions and Route Handlers. Client components should only see NEXT_PUBLIC_ values. Vercel's "Automatically expose System Environment Variables" option is separate from your own keys; turning it on does not add your Supabase or Stripe secrets.
Replit. Workspace Secrets are not always the same as Deployment Secrets. After you publish, open the deployment settings and confirm each key is present there too.
Self-hosted / cPanel / Docker. For a static Vite build, values must exist at build time. For a Node server, they must exist in the process environment at runtime (export in the start script, a systemd unit, or your host's env UI).
How to ask an AI tool to help without leaking secrets
Never paste real keys into a chat. Paste the names and the error:
On Vercel Production, the live site console shows: Uncaught Error: supabaseUrl is required Code reads import.meta.env.VITE_SUPABASE_URL. .env.example lists VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY. Do not invent new variable names. Tell me exactly what to set where, and whether I need to redeploy.
If the tool suggests renaming a secret to a public prefix, refuse. That's how keys end up in the browser bundle.
FAQ
Why is import.meta.env undefined in my production Vite build?
Vite only exposes variables prefixed with VITE_, and it replaces them at build time. If the variable was missing or misnamed when the build ran, it is undefined in the bundle. Set it on the host and redeploy.
Do I need to redeploy after changing an environment variable on Vercel?
Yes. Public variables (VITE_, NEXT_PUBLIC_) are inlined at build time, and new values apply only to new deployments. If a cached build keeps the old value, redeploy without the build cache.
Can I prefix my OpenAI or Stripe secret key with NEXT_PUBLIC_?
No. Public prefixes put the value into JavaScript that anyone can download. Keep secrets server-side and call them from a Route Handler, Server Action or edge function.
I committed my .env file to GitHub. What should I do?
Rotate every key in it right away, because it remains in git history even after you delete the file. Then add .env to .gitignore and commit only a .env.example with names, not values.