Why your Next.js 16 build crashes on a missing env var and a 5-line Proxy fix
You hit deploy. The build runs. Halfway through “Collecting page data,” it dies. Your local dev works fine — the env var is set on the…
Why your Next.js 16 build crashes on a missing env var and a 5-line Proxy fix
You hit deploy. The build runs. Halfway through “Collecting page data,” it dies. Your local dev works fine — the env var is set on the production server, just not in the CI box. You add it to GitHub Actions, re-trigger, wait. Same error. This is not a config problem. It is a timing problem.
Error: Missing SUPABASE_SERVICE_ROLE_KEY env var
at getClient (src/lib/supabase/admin.ts:14)
at Module.<anonymous> (src/lib/supabase/admin.ts:24)
at /app/.next/server/app/api/webhooks/stripe/route.js
What is actually breaking
When you run next build, Next.js does a phase called Collecting page data. To produce static metadata for each route, Next has to import every route module. Importing a module evaluates its top-level code. If your SDK client is constructed at module level — like calling createClient() outside of any function — that constructor runs the moment the module is imported, before any request ever arrives. One missing env var on the build box, and the whole build fails. You will hit this with Supabase, Resend, Polar, Stripe, OpenAI, Anthropic, Sentry — every SDK that takes secret config in its constructor.
What does not fix it
Worth listing because most teams try these in order before finding the real fix:
dotenv-flow or .env.production: Your env vars probably exist at runtime. The issue is that the build box does not have them, or has them set as runtime-only. Loading .env.production at build only helps if you check it into the repo, which you should not.
next.config.ts env block: Forwards env vars from the build environment into the bundle. Does not change when the SDK constructor runs.
Moving the import inside a function: Works for that one file. Fragile. Do it once, forget to do it for the next SDK, break production in three months.
try { createClient(…) } catch {} at module level: Silences the error at build time. Now you have a supabase export that is undefined and your runtime explodes the first time anything calls it. You moved the bug from a CI failure to a production 500.
The fix: lazy Proxy
Here is the production module that ships in every Boilerlykit template:
import "server-only";
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
let _client: SupabaseClient | null = null;
function getClient(): SupabaseClient {
if (_client) return _client;
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!url || !serviceKey) {
throw new Error(
"Missing SUPABASE env: NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY",
);
}
_client = createClient(url, serviceKey, {
auth: { autoRefreshToken: false, persistSession: false },
});
return _client;
}
export const supabaseAdmin = new Proxy({} as SupabaseClient, {
get(_target, prop, receiver) {
return Reflect.get(getClient(), prop, receiver);
},
});
Twelve lines. Replaces the original three. The key insight: _client is a module-level cache constructed once and reused. getClient() does the env read and the actual createClient() call, and throws only when called, never at module import. supabaseAdmin is exported as a Proxy over an empty object typed as SupabaseClient, so TypeScript sees a real SupabaseClient and gives you full IDE completion. On the first property access, getClient() constructs the real client. On every subsequent access, the cache returns it.
The same pattern, every SDK
Once you have written it once, every other lazy-init SDK becomes a near-identical file. Here is the Resend version:
import "server-only";
import { Resend } from "resend";
let _resend: Resend | null = null;
function getResend(): Resend {
if (_resend) return _resend;
const apiKey = process.env.RESEND_API_KEY;
if (!apiKey) throw new Error("Missing RESEND_API_KEY");
_resend = new Resend(apiKey);
return _resend;
}
export const resend = new Proxy({} as Resend, {
get(_target, prop, receiver) {
return Reflect.get(getResend(), prop, receiver);
},
});
Three SDKs, one pattern. The cost of writing the file is fixed — it does not grow with the number of SDKs you wrap.
When to use this — and when not to
Use it for: SDK clients that read env vars in their constructor and throw on missing config (Supabase admin, Resend, Polar, Stripe, OpenAI, Anthropic, Sentry server-side). Any module-level singleton whose construction depends on runtime config. Mark server-only modules with import “server-only” at the top so a stray client import fails loudly instead of leaking secrets.
Do not use it for: pure config objects where you just read process.env.SOME_VAR inside a function (the Proxy adds nothing). Client-side code — NEXTPUBLIC* vars are baked into the client bundle at build time. Modules where you actually want the build to fail on missing config — if a critical feature flag is required for the app to function, an explicit build-time failure is better than a deferred runtime failure that hits a customer.
Five lines of Proxy, one helper function, and next build stops caring whether your runtime env vars are present at build time. The pattern generalizes to every SDK in your codebase. This pattern ships in every Boilerlykit template — SaaSForge Starter, Core, Agency, and AI all wrap their SDK clients this way.
Originally published on the Boilerlykit blog: https://boilerlykit.com/blog/nextjs-build-crashes-missing-env-lazy-proxy
메타데이터
- post_id
- dbd8acf345be
- slug
- why-your-next-js-16-build-crashes-on-a-missing-env-var-and-a-5-line-proxy-fix-dbd8acf345be
- url
- https://medium.com/@boilerlykit/why-your-next-js-16-build-crashes-on-a-missing-env-var-and-a-5-line-proxy-fix-dbd8acf345be
- canonical_url
- https://medium.com/@boilerlykit/why-your-next-js-16-build-crashes-on-a-missing-env-var-and-a-5-line-proxy-fix-dbd8acf345be
- author_url
- https://medium.com/@boilerlykit
- status
- ok
- fetched_at
- 2026-08-01 11:27:04