← Blog

Next.js build failing on missing env vars? Make your Stripe and database clients lazy

Module-level Stripe and database clients break next build without secrets. Lazy singletons, presence guards, and deciding fail-open vs fail-closed.

The first time CI runs next build without your production secrets, it fails somewhere you were not looking: a route module that does new Stripe(process.env.STRIPE_SECRET_KEY!) at the top of the file. During the build, Next.js imports your route and page modules to collect data and prerender, so every client constructed at module scope runs at build time — and throws when the key is not there. Your marketing pages cannot build because your billing webhook wanted a secret. The fix is to construct clients when they are first used, not when the file is imported.

Lazy singletons instead of module-level clients

Each external client gets a small accessor that builds it on first call, caches it, and fails with an actionable message if the key is genuinely missing at the moment it is needed:

let client: Stripe | null = null;

export function stripe(): Stripe {
  if (!client) {
    const key = process.env.STRIPE_SECRET_KEY;
    if (!key) {
      throw new Error("STRIPE_SECRET_KEY is not set. Add it to .env.local to enable billing.");
    }
    client = new Stripe(key);
  }
  return client;
}

Importing the module now does nothing; only calling stripe() does. The kit uses the same shape for the Anthropic client and the database, so the whole app — marketing pages, blog, the lot — builds with zero secrets, and the CI workflow runs typecheck, lint, and a full production build without any env configured. Note what the error message does: it names the variable and says where to put it. That line is the difference between a five-second fix and a support ticket.

The database version: a placeholder that never connects

The database accessor can go one step further. The postgres.js driver connects lazily — on the first query, not on construction — so building the Drizzle client with a placeholder URL is safe:

const PLACEHOLDER_URL = "postgres://placeholder:5432/placeholder";

function create() {
  const sql = postgres(process.env.DATABASE_URL ?? PLACEHOLDER_URL, { max: 5 });
  return drizzle(sql, { schema });
}

export function db() {
  return (instance ??= create());
}

export function hasDatabase(): boolean {
  return Boolean(process.env.DATABASE_URL);
}

Nothing touches the network until a real query runs, and a real query without a real URL still fails loudly. The small pool (max: 5) is deliberate too: route handlers share the process, and serverless Postgres providers meter connections.

Presence guards: decide what each path does without the service

Lazy clients stop the build from failing. They do not answer the runtime question: when a request arrives and the service is not configured, what should this code path do? A pair of presence checks — hasDatabase() and hasStripe() — lets each path choose deliberately, and the right answer differs by path:

  • Fail open where the feature is optional. Metering skips the write and logs a warning when there is no database, and the quota check allows the request, so the public demo keeps working with nothing configured.
  • Fail closed where pretending would be wrong. The checkout route returns 503 Billing is not configured instead of trying to talk to Stripe — there is no sensible way to take money without a payment provider.

Writing that decision into each path, instead of letting a missing key surface as a stack trace, is what makes the app runnable in every environment from a fresh clone to production.

Things that bite

  • Fail-open in production is a spend leak. A quota that allows every request when the database is unreachable is perfect for a demo and dangerous for a live paid app — the free-tier safety layer only bounds spend if it actually runs. Decide per environment, on purpose.
  • Presence is not validity. hasStripe() checks that a key exists, not that it works. A revoked or test-mode key in production passes the guard and fails at the first call — log it clearly when it does.
  • Avoid the non-null assertion. process.env.KEY! silences the type checker and moves the failure to runtime with a worse message. Read the value, check it, and throw something useful.
  • Let the SDK pin its own API version. The kit omits Stripe's apiVersion so the installed SDK's pinned default and its types always agree — the same verify-against-installed-code habit behind the Stripe renewal-date gotcha.

Shipwright — the Next.js and Claude starter kit this blog documents — ships every external client this way, which is why the live demo runs on the same code a buyer clones before any billing is configured.

Shipwright is a Next.js 16 + Claude starter kit that ships these patterns already done.

Try the live demo →