How to catch a missing env var in Next.js before it reaches production
A Next.js app that reads process.env.STRIPE_SECRET_KEY builds and deploys without it, then fails on the first request that needs it. A NEXT_PUBLIC_ variable is worse: Next.js inlines it at build time, so a missing one ships as undefined in the browser.
deployhealth compares the variables your code reads with the ones your env files declare, on every pull request and every push, and names each gap with its file and line. It reads names and line numbers only, never values.
What deployhealth reads in a Next.js app
In .ts, .tsx, .js, .jsx, .mjs, .cjs, .mts and .cts files, on one line each:
process.env.NAMEandprocess.env?.NAMEprocess.env["NAME"], with any quotesimport.meta.env.NAME- destructuring:
const { A, B: alias, C = "x" } = process.env - client components’
NEXT_PUBLIC_reads, like any other
A route handler, a client component and a database module, with a .env.example that declares three variables:
DATABASE_URL=
STRIPE_SECRET_KEY=
NEXT_PUBLIC_POSTHOG_KEY=
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env['STRIPE_WEBHOOK_SECRET'];
'use client';
export const posthogKey = process.env.NEXT_PUBLIC_POSTHOG_KEY;
export const { DATABASE_URL, DB_POOL_SIZE = '10' } = process.env;
What deployhealth reports
STRIPE_SECRET_KEYapp/api/checkout/route.ts:3 · requiredSTRIPE_WEBHOOK_SECRETapp/api/checkout/route.ts:4 · requiredNEXT_PUBLIC_POSTHOG_KEYapp/providers.tsx:3 · requiredDATABASE_URLlib/db.ts:1 · requiredDB_POOL_SIZElib/db.ts:1 · optional (has a default)
MISSING STRIPE_WEBHOOK_SECRET
In a monorepo, each directory with its own env files (say apps/web) is checked against its own .env.example.
Defaults and OPTIONAL
A read with a fallback on the same line, ?? "x" or || "x" (or a default in destructuring), can’t be MISSING: when no env file declares it, the scan lists it as OPTIONAL instead (--show-optional prints each one). A fallback of undefined or null isn’t a default.
Three fallbacks, one of which isn’t:
NEXT_PUBLIC_SITE_URL=
export const config = {
siteUrl: process.env.NEXT_PUBLIC_SITE_URL,
region: process.env.AWS_REGION ?? 'us-east-1',
logLevel: process.env.LOG_LEVEL || 'info',
sentryDsn: process.env.SENTRY_DSN ?? undefined,
};
What deployhealth reports
NEXT_PUBLIC_SITE_URLlib/config.ts:2 · requiredAWS_REGIONlib/config.ts:3 · optional (has a default)LOG_LEVELlib/config.ts:4 · optional (has a default)SENTRY_DSNlib/config.ts:5 · required
MISSING SENTRY_DSN
What it skips
- build output and vendored code:
node_modules/,.next/,out/,dist/,.vercel/,.turbo/,coverage/and the like, minified*.min.jsfiles, and any source file over 512 KB - tests and test tooling:
*.test.*,*.spec.*,__tests__/,e2e/,playwright/,cypress/,__mocks__/and runner configs (--include-testsreads them) - names the platform provides:
NODE_ENV,NEXT_RUNTIME,CI,VERCEL_*,NEXT_PUBLIC_VERCEL_*and GitHub Actions’ own variables (--no-default-ignorechecks them too)
Only the read in lib/ counts here:
DATABASE_URL=
const id = process.env.BUILD_CACHE_ID;
const token = process.env.TEST_ONLY_TOKEN;
export const url = process.env.DATABASE_URL;
export const isProd = process.env.NODE_ENV === 'production';
export const preview = process.env.VERCEL_URL;
What deployhealth reports
DATABASE_URLlib/db.ts:1 · required
The CLI still reads test files to see which declared variables they use, so a variable only tests read isn’t reported UNUSED.
Known limits
- A read split across lines, or with a computed name (
process.env[key]), isn’t seen. - A variable set only in your hosting platform’s settings is MISSING until an env file declares it: list it in
.env.example, with an empty value. - MISMATCH compares
.envwith.env.exampleonly;.env.localand.env.productioncount as declaring a variable, but aren’t compared.
A computed name reads nothing:
export const flag = (key: string) => process.env[key];
What deployhealth reports
No reads.
Set it up
Pull request checks
Sign in with GitHub, add a project for the repository (owner/repo) and install the deployhealth GitHub App from the project's settings. Every pull request that reads an env var no .env.example declares gets a comment and a check. No token, no secret, no workflow.
Deploy history and alerts
Add the project's ingest token as the repository secret DEPLOYHEALTH_TOKEN (a secret, not a variable) and scan every push. The project's settings page gives you a workflow that runs the CLI pinned to an exact version, npx --yes [email protected], which you can also run from any other CI.
Or use the deployhealth env check Action:
# .github/workflows/deployhealth.yml
name: deployhealth
on:
push:
branches: [main]
jobs:
env-scan:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: Utsav-mishra-25/deployhealth-action@v1
with:
token: ${{ secrets.DEPLOYHEALTH_TOKEN }}
@v1 follows the latest 1.x release and can move; pin a full commit SHA if you want it fixed. An npm version never changes once published.
Try it now, without signing up
From your repository's root. It prints MISSING, UNUSED and MISMATCH with file and line in your terminal, and sends nothing:
npx -y deployhealth-scan --dry-run