Skip to main content

Why your Next.js 16 build crashes on a missing env var and a 5-line Proxy fix

CategoryNext.js
PublishedApr 26, 2026
Reading time9 min read
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:

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

Your local dev works. Your runtime would work fine if the build would only finish - the env var is set on the production server, just not in this CI box. You add it to GitHub Actions, you re-trigger, you wait. Same error. You set it as a build-arg in the Dockerfile. Same error. You add dotenv-flow. Same error.

This is not a config problem. It is a timing problem. Next.js evaluates your module at build time, your SDK constructor reads process.env at module-load time, and the env var only exists at runtime. The fix is a five-line Proxy that defers SDK construction past the dangerous phase. Every other approach is a workaround.

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 - the page title, the OG image, the static-vs-dynamic decision - Next has to import every route module. Importing a module evaluates its top-level code. If you have:

// src/lib/supabase/admin.ts
import { createClient } from "@supabase/supabase-js";

export const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!,
);

...then createClient(...) runs the moment the module is imported. The non-null assertion ! will not save you - if the value is undefined, the Supabase SDK throws inside its own constructor.

This module is imported transitively by any route that uses supabase. Next has to evaluate every route module to collect page data. So even if the route handler that uses supabase is only invoked at runtime, the import chain is run at build time. One missing env var on the build box, and the whole production build fails before a single page is rendered.

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:

  • dotenv-flow or .env.production. Your env vars probably do 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. Now do it for every other SDK module in your codebase, and remember to do it for every new one. Fragile.
  • Top-level if (process.env.NODE_ENV === "production") { ... }. Build runs in NODE_ENV=production. The condition is true. The constructor still runs. The build still crashes.
  • 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 actual answer is to defer construction itself - construct the SDK client only when its first method is called.

The fix - lazy Proxy

Here is the pattern. This is the production module from one of our boilerplates, copied verbatim:

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. Walk through it:

  • _client is a module-level cache. Construct once, reuse forever.
  • getClient() does the env read and the actual createClient call. Throws only when called, never at module import.
  • supabaseAdmin is exported as a Proxy over an empty object typed as SupabaseClient. TypeScript sees a real SupabaseClient and gives you full IDE completion.
  • The get trap fires on every property access (supabaseAdmin.from(...), supabaseAdmin.auth.getUser()). On the first access, getClient() constructs the real client. On every subsequent access, the cache returns it.
  • Reflect.get(target, prop, receiver) is the right way to forward property reads - it preserves the receiver argument so chained method calls bind this correctly. Plain getClient()[prop] also works for most SDKs but fails on the small number that do this-tricks internally.
  • import "server-only" is a guard rail. If anything client-side accidentally imports this module, the build fails loudly with a clear message instead of leaking your service-role key into a client bundle.

Now next build runs Collecting page data. It imports src/lib/supabase/admin.ts. The import succeeds because nothing at module level reads env. At runtime, the first time a route handler does supabaseAdmin.from("users"), getClient() runs, finds the env vars that exist at runtime, and constructs the client.

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.
  • Server-only modules. Mark them 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. If you just need to read process.env.SOME_VAR inside a function, do that. The Proxy adds nothing.
  • Client-side code. Public env vars (NEXT_PUBLIC_*) are baked into the client bundle at build time. If they are missing at build, they are missing at runtime regardless of how late you defer.
  • 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.

Caveats worth knowing

Errors move from build time to runtime. This is the trade you are making. The build now passes even if the env var is genuinely missing in production. Make sure your monitoring catches errors from these helpers - the message "Missing SUPABASE env" should page you, not get buried in logs.

Some SDKs hold internal references during construction. The Proxy intercepts every property access on the exported client. If the SDK internally does this._myBoundThing and then exposes it as a property, that internal reference is still the un-Proxied client. In practice this has not been a problem with Supabase, Resend, Polar, Stripe, OpenAI, or Anthropic. Test your specific SDK once after wrapping.

instanceof checks fail through the Proxy. If any code does client instanceof SupabaseClient, that returns false. None of the SDKs above do this on themselves; user-land code occasionally does. If you find one, that code needs to call getClient() directly instead of going through the export.

A 30-second test

After wrapping a client, verify the fix is real. Two checks:

# 1. Build with the env var INTENTIONALLY missing - should still pass.
unset SUPABASE_SERVICE_ROLE_KEY
pnpm build  # exit 0

# 2. Runtime should still throw clearly when the env is genuinely missing.
unset SUPABASE_SERVICE_ROLE_KEY
pnpm start
# Hit a route that uses supabaseAdmin - should return 500 with the clear error.

If step 1 fails, the lazy Proxy is not wired up correctly - probably a stray top-level call somewhere in the import chain. If step 2 silently succeeds, the env was actually set somewhere. Both failures are easy to diagnose because the error messages are explicit.

Close

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 - once you have written it once, the second one is a 12-line file you can almost paste from memory.

This pattern ships in every Boilerlykit template - SaaSForge Starter, Core, Agency, and AI all wrap their SDK clients this way. If you want the full set of server modules already wired with this pattern, Boilerlykit is the shortcut. If you just need the snippet, the snippet is right there - copy it, ship, move on.

B

Boilerlykit Team

Engineering