Symptoms
- VITE_SUPABASE_SERVICE_ROLE or NEXT_PUBLIC_STRIPE_SECRET_KEY in .env or in Vercel.
- Production works only because a secret was made public. Localhost works because .env has everything.
- Preview deploys charge live Stripe keys, or production still talks to a personal OpenAI key that lives in a VITE_ var.
- You rotated a key in the dashboard and nothing changed until you realized the old value is in last week’s JS bundle.
Why the builders get the prefix wrong
Vite will not expose process.env.FOO to the client unless it starts with VITE_. The generator needs the Supabase URL in the browser, so it prefixes everything “to be safe.” That includes the service role, the Stripe secret, and the OpenAI key. Bolt and Lovable apps copy that file forward. v0 does the same with NEXT_PUBLIC_.
The actual split
Public (prefix allowed): Supabase URL, Supabase anon key, Stripe publishable key (pk_), public Sentry DSN, public site URL.
Secret (no prefix, server / Vercel only): SUPABASE_SERVICE_ROLE_KEY, STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, OPENAI_API_KEY, RESEND_API_KEY, DATABASE_URL, any AWS_SECRET name.
How to set them
- Vercel, Settings, Environment Variables. Create each secret without a VITE_ or NEXT_PUBLIC_ prefix.
- Tick Production / Preview / Development on purpose. Preview should use Stripe test keys. Production should not.
- After changing a public var, Redeploy. They are inlined at build time.
- Keep .env and .env.local out of git. Pull with the Vercel CLI if you want a local file.
# names that should never exist VITE_SUPABASE_SERVICE_ROLE VITE_STRIPE_SECRET_KEY NEXT_PUBLIC_SERVICE_ROLE NEXT_PUBLIC_STRIPE_SECRET
A table of names lives in kit/vercel-env.md. Pair this with the service_role and white screen pages — they are the same mistake from two other angles.