Most “env is undefined in production” threads are not mysterious. Someone added the key in the Vercel dashboard, left Preview checked and Production unchecked, or changed a NEXT_PUBLIC_* value and never redeployed. The dashboard looks fine. The merge goes green. The custom domain is wrong for three hours.
I already wrote the full Next.js / Vercel go-live pass and a Polar sandbox vs live webhook deep dive. This piece is narrower: how to prove your env matrix is sane before you merge, on your laptop, without uploading secrets to a third-party scanner.
Official Vercel and Next.js docs win when they disagree with anything here.
What an “env matrix” actually is
Vercel has three deployment worlds. Each secret either belongs in a world or it does not.
| Key family | Development | Preview | Production |
|---|---|---|---|
Public site URL / AUTH_URL
|
http://localhost:3000 |
Preview origin or staging domain | Custom domain |
AUTH_SECRET |
Laptop-only value | Separate preview value | Separate live value |
| Billing tokens (Polar, Stripe, …) | Sandbox / test | Sandbox / test | Live org only |
| Database URL | Local or personal branch | Disposable branch / staging | Main / prod project |
| Transactional email | Test recipients | Test recipients | Verified domain |
NEXT_PUBLIC_* feature flags |
Whatever you are debugging | Safe defaults | Live defaults |
If you cannot fill every cell for every secret your app reads, you do not have a matrix — you have hope.
The matrix is not a Vercel feature. It is a table you own that answers three questions per key:
- Is this key required for a healthy deploy of this world?
- Which Vercel scopes should be ticked?
- After I change it, do I need a new build (anything
NEXT_PUBLIC_*) or is a server restart / new Function deploy enough?
The failure mode a matrix catches that next build will not
next build on your laptop uses .env.local. Vercel Preview uses Preview-scoped dashboard values. Production uses Production-scoped values. Those three sets can diverge forever while CI stays green.
Typical silent split:
Laptop: POLAR_ACCESS_TOKEN=<sandbox> POLAR_SERVER=sandbox
Preview: POLAR_ACCESS_TOKEN=<sandbox> POLAR_SERVER=sandbox ✅
Production: POLAR_ACCESS_TOKEN=<sandbox> POLAR_SERVER=production ❌
Build: pass. Checkout button: may even render. Entitlements / API calls: confusing 401s. The Polar webhook article covers the delivery side; the matrix catches the token–server mismatch before you post the buy link.
Same class of bug with Neon pooled vs direct, Resend onboarding@resend.dev still in Production, or NEXT_PUBLIC_APP_URL still pointing at *.vercel.app after you attached a domain.
Build the matrix from files you already have
You do not need a SaaS. Start from three artifacts:
-
Required keys — your committed
.env.example(names only, never values). -
Filled local file —
.env.localor a redacted export fromvercel env pull(Development only — that CLI pull does not magically give you Production). - Dashboard truth — a manual pass over Vercel → Settings → Environment Variables, or a screenshot you keep private.
Step 1 — Diff required vs present
For every key in .env.example:
- Present in local filled file?
- Empty (
KEY=with nothing after=)? Flag it. Empty is worse than missing — Next may treat it as “set.” - Orphan in local file but not in
.env.example? Either document it or delete it. Orphans are how old Stripe keys survive Polar migrations.
A 20-line script is enough if you like shells:
#!/usr/bin/env bash
# compare-env-keys.sh — names only; never prints values
set -euo pipefail
example="${1:-.env.example}"
filled="${2:-.env.local}"
keys() {
grep -E '^[A-Za-z_][A-Za-z0-9_]*=' "$1" | cut -d= -f1 | sort -u
}
comm -23 <(keys "$example") <(keys "$filled") | sed 's/^/MISSING: /'
comm -13 <(keys "$example") <(keys "$filled") | sed 's/^/ORPHAN: /'
while IFS= read -r line; do
key="${line%%=*}"
val="${line#*=}"
if [[ -z "${val}" ]]; then
echo "EMPTY: $key"
fi
done < <(grep -E '^[A-Za-z_][A-Za-z0-9_]*=' "$filled")
Run it in CI against a fixture .env that only contains placeholder non-secrets if you want a gate. Do not commit real .env.local.
Step 2 — Assign scopes on paper (or Markdown)
Create docs/env-matrix.md in the repo (values never committed):
| Key | Dev | Preview | Production | Notes |
| --- | --- | --- | --- | --- |
| DATABASE_URL | ✓ local | ✓ branch | ✓ pooled prod | runtime = pooled |
| DATABASE_URL_UNPOOLED | ✓ | ✓ | ✓ | migrations only |
| POLAR_ACCESS_TOKEN | sandbox | sandbox | live | never NEXT_PUBLIC |
| POLAR_SERVER | sandbox | sandbox | production | must match token |
| POLAR_WEBHOOK_SECRET | sandbox ep | sandbox ep | live ep | matched set |
| NEXT_PUBLIC_APP_URL | localhost | preview URL | https://app.example | redeploy after change |
| AUTH_SECRET | laptop | preview-only | prod-only | 32+ bytes, distinct |
| EMAIL_FROM | resend.dev | resend.dev | verified domain | |
Tick columns the way Vercel’s checkboxes work. “Apply to all” is fine for true public config (NEXT_PUBLIC_MARKETING_SITE=https://…). It is wrong for every billing and database secret.
Step 3 — Mark rebuild vs runtime
| Prefix / consumer | Change requires |
|---|---|
NEXT_PUBLIC_* |
New build (value inlined at next build) |
Server-only (POLAR_*, DATABASE_URL, …) |
New deploy of server / Functions; no client bundle bake |
| Edge middleware reading env | Confirm it sees the same scope; redeploy middleware |
If you edit NEXT_PUBLIC_APP_URL in the dashboard and skip redeploy, production keeps the old origin. Auth loops and “wrong redirect URI” reports follow.
A PR checklist that takes five minutes
Paste this into the PR template. Require a human checkbox before merge to main:
### Env matrix (required for main)
- [ ] `.env.example` lists every key this PR newly reads
- [ ] No new secret uses the `NEXT_PUBLIC_` prefix
- [ ] Preview-scoped values are sandbox / branch / test-mail only
- [ ] Production-scoped values are prepared in the dashboard **before** merge (or this PR is docs-only)
- [ ] If any `NEXT_PUBLIC_*` changed: redeploy plan noted in the PR body
- [ ] Polar / billing: token origin matches `POLAR_SERVER` (see webhook article)
- [ ] Webhook URL for live org is the custom domain, not a preview hostname
This does not replace a go-live smoke test. It stops the most common self-own: merging feature work that assumes Production secrets that were never created.
Offline tool vs checklist (when each wins)
A Markdown checklist (like the launch kit article) teaches what to set. An offline matrix tool earns its keep when you are staring at your messy .env.local and need a missing-key report plus a filled Production / Preview / Development table without uploading secrets.
I built a small browser-only helper for that job — paste .env.example + filled env, get a report and a scope matrix export. Parsing stays on your machine:
Deploy Guard — Offline Env Matrix for Vercel ($12):
https://buy.polar.sh/polar_cl_fXjUOUonsouTBvvQLXBVvd1VdA2hVaw6LeBi32fuIjc
If you want the longer ~60-point go-live pass plus five annotated .env.example packs (Next/Vercel, Polar, Neon, Resend, Supabase), that is the companion download:
Next.js / Vercel Production Launch Kit ($19):
https://buy.polar.sh/polar_cl_uxzixqNM81nkPoIW3um9aacYzAmA1bXQbWqUe3EQE75
Neither is required to use the bash diff or the Markdown matrix above. Buy only if you want the packaged version; the free method is enough for a solo app with under ~40 keys.
Wire a cheap CI gate (names only)
GitHub Actions can fail a PR when .env.example drifts from code without ever seeing secrets:
# .github/workflows/env-example.yml
name: env-example
on:
pull_request:
paths: ['.env.example', '**/*.{ts,tsx,js,jsx,mjs}']
jobs:
keys-mentioned:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Fail if example is empty
run: |
test -s .env.example
grep -qE '^[A-Za-z_][A-Za-z0-9_]*=' .env.example
Stronger gates (AST-scan for process.env.FOO vs example) are worth it once the team is >1. For a solo indie app, the PR checklist + offline diff before you touch Production scopes is the higher ROI habit.
Never run vercel env pull --environment=production in CI and commit the result. Production values stay in the dashboard (Sensitive) or a secrets manager — not in Actions logs.
Evening-of merge sequence (env only)
When the PR is ready and you are about to ship:
- Open
docs/env-matrix.md(or Deploy Guard export). Confirm every Production cell has a dashboard row with Production ticked and Preview unticked for live secrets. - Confirm Preview still has sandbox billing + branch DB + test email.
- Change any
NEXT_PUBLIC_*Production values before the merge, then expect the production deploy to rebuild. - Merge → wait for Production deploy → private window smoke: home, auth, one write, one billing path if you have it.
- If Polar is in the mix: live webhook delivery page must show a recent 2xx (details in the webhook article).
Rollback is Instant Rollback on Vercel plus knowing whether you migrated the database forward. Env mistakes rarely need a DB rollback; wrong migrations do.
Quick reference — symptoms → matrix cell
| Symptom | Matrix cell to inspect first |
|---|---|
Runtime undefined for a server secret |
Production scope missing; or only set on Preview |
| Public config wrong after dashboard edit |
NEXT_PUBLIC_* without redeploy |
| Works on PR URL, fails on custom domain |
NEXT_PUBLIC_APP_URL / AUTH_URL still preview origin |
| Billing 401 after go-live | Token world ≠ POLAR_SERVER / SDK server
|
| Checkout paid, app still free-tier | Webhook URL / secret world (not an env-matrix-only bug — see webhook post) |
too many connections on Neon |
Runtime using unpooled URL in Production |
Ship rule
Merge when Preview was green on non-production secrets, the Production column of your matrix is filled with live-only values, and you know whether the next deploy must rebuild public env. Post the URL after the private-window smoke — not after the GitHub check turns green alone.
Educational material only. Re-read Vercel’s environment variables docs and the Next.js production checklist before you ship. Vendor names belong to their owners.
Top comments (0)