DEV Community

Cover image for I built a storefront that works with zero API keys, and it made the code better
Zarar Ahmed
Zarar Ahmed

Posted on

I built a storefront that works with zero API keys, and it made the code better

I sell Next.js templates. The store takes payments through Stripe, stores orders in Postgres, keeps ZIPs in object storage, and emails download links through Resend.

It also runs perfectly with none of those configured. Clone it, npm install, npm run dev, and you get a working storefront: you can browse the catalog, "buy" a template, and download a real ZIP file. No .env. No accounts. No
keys.

That started as a convenience. It turned into the single best architectural decision in the codebase, and it's the reason I can hand the repo to someone and have them productive in ninety seconds.

The rule

Every integration is optional and independently detected. That's the whole idea, and it lives in about eight lines:

export const integrations = {
  db: Boolean(process.env.DATABASE_URL),
  stripe: Boolean(process.env.STRIPE_SECRET_KEY),
  s3: Boolean(
    process.env.S3_BUCKET &&
    process.env.AWS_ACCESS_KEY_ID &&
    process.env.AWS_SECRET_ACCESS_KEY,
  ),
  email: Boolean(process.env.RESEND_API_KEY),
};

/** True when Stripe isn't configured - checkout simulates a purchase. */
export const demoMode = !integrations.stripe;
Enter fullscreen mode Exit fullscreen mode

Note that these are four independent booleans, not one NODE_ENV check and not a single DEMO=true flag. That distinction is what makes it useful rather than cute. You can run with a real database and no Stripe. Or real Stripe and no object storage. Each combination is a valid, working configuration, because every consumer degrades on its own.

What "degrades" actually means

The temptation with a flag like this is to throw a banner up and disable things. That's not degradation, that's a broken site with an apology on it. The rule I settled on: the user-visible flow must complete. Not just "show an
error nicely".

No database. The product catalog is a TypeScript file. When a database is connected it mirrors that catalog into a table and lets rows override commerce fields — price, name, active. When it isn't, the catalog is the data:

export async function getProducts(): Promise<Template[]> {
  const db = getDb();
  if (!db) return listedTemplates;
  try {
    const rows = await db.query.products.findMany();
    if (rows.length === 0) return listedTemplates;
    // ...merge DB overrides onto the static catalog
  } catch (err) {
    console.error("products: falling back to static catalog:", err);
    return listedTemplates;
  }
}
Enter fullscreen mode Exit fullscreen mode

The catch matters more than the if. A database that's configured but unreachable degrades to exactly the same place as a database that was never configured. Production has been down to that path during a Postgres restart
and the store stayed browsable.

No Stripe. Checkout signs a short-lived access grant and redirects to the success page, which is the same page a real purchase lands on. You get the same confirmation, the same download button, the same email prompt. Two hours
of validity instead of a permanent entitlement, and every token is stamped demo: true so nothing downstream can mistake one for a real sale.

No object storage. This is my favourite one, because the honest answer was harder than the lazy one. The lazy version returns a 404 or a text file named template.zip. The download is the product; a fake download makes the whole demo worthless.

So the repo contains a small ZIP writer — about 190 lines, store method, no compression — that generates a valid archive on the fly:

// Fixed DOS date: 2026-01-01, so archives are byte-stable.
const DOS_DATE = ((2026 - 1980) << 9) | (1 << 5) | 1;
Enter fullscreen mode Exit fullscreen mode

You get a real .zip that a real unzip program opens, containing a README explaining that storage isn't configured. It is genuinely a working download,
which is the point.

That writer earned its keep later. Every real download now streams the ZIP through the app and injects a per-buyer license file, using the same code to append a stored entry to an existing archive without recompressing it. I
wouldn't have written a ZIP encoder to solve watermarking. I wrote it to make demo mode honest, and watermarking came free.

No email. Here the degradation is deliberately not transparent, and it's worth explaining why.

The library flow, enter your email and get a magic link to your purchases, sends the link to the address on file. That's the entire security model: having the link is tied to controlling the inbox. With no mail provider, "degrading gracefully" would mean returning the link in the HTTP response, and that would hand anyone the library for any address they can spell.

So this one fails closed, and it fails identically for every input:

if (!integrations.email) {
  return NextResponse.json(
    { error: "Library links are temporarily unavailable. Please contact support." },
    { status: 503 },
  );
}
Enter fullscreen mode Exit fullscreen mode

The same endpoint also returns an identical response whether or not an address has purchases, so it can't be used to enumerate who my customers are.

That's the real lesson. Demo mode is not "make everything work anyway." It's "decide, per integration, what the honest fallback is" — and sometimes the
honest fallback is a clean refusal.

What it bought me

Onboarding is npm install. No credential-sharing ritual, no .env.example scavenger hunt to get a first page render.

Most development doesn't touch a paid API. Building the checkout UI, the success page, the library, the download flow — none of it needs Stripe keys. Test-mode webhooks are for verifying the integration, not for laying out a
button.

Failure paths are the default path. This is the one I didn't anticipate. In a normal codebase the "database is down" branch is written once, never executed, and quietly rots. Here it's the branch that runs every time I start
the dev server. It cannot rot, because it's the one I use most.

It forced a real answer about the source of truth. "What happens with no database?" is unanswerable if the database is the product data. Making demo mode work meant deciding that code is canonical and the database holds
overrides. That's a better architecture, and I only reached it because the constraint refused to let me be vague.

What it costs

Two things, honestly.

Every integration point needs a branch, and each branch is a decision, not a default. Four integrations across roughly 5,500 lines is very manageable.
Twenty would not be.

Unexercised branches can lie. The fallback paths run constantly in development, but the real paths only run in production. I've shipped a bug where the demo path worked and the real one didn't — the mirror image of the
usual problem, and just as annoying. Demo mode is not a substitute for integration tests against the real thing.

The tradeoff has been overwhelmingly worth it, but "every integration is optional" is a discipline with a running cost, not a free lunch.

The bit I'd steal for any project

Skip the storefront specifics — the transferable idea is one sentence:

Detect each dependency independently, and define an honest fallback for each one, where "honest" sometimes means refusing.

Not one DEMO flag. Not NODE_ENV === "development". A boolean per integration, and a real decision about what the product does without it. You'll end up with software that runs anywhere, onboards in one command, and —
because the degraded paths are the ones you use daily — degrades correctly when it counts.


I built Nocturne, a small collection of dark, animation-heavy Next.js templates. The free starter, Ember, is MIT on GitHub
and uses the same design system — no email required, though there's a form if you'd rather have the ZIP.

Top comments (1)

Collapse
 
merbayerp profile image
Mustafa ERBAY

This is one of the best explanations of graceful degradation I’ve read because you treat each integration as an independent capability rather than hiding everything behind one demo flag.

The email example is especially strong. “Make the flow complete” is a useful default, but preserving the security model matters more than pretending every feature can degrade transparently. Sometimes the correct fallback really is a clean refusal. One operational nuance I’d add: “not configured” and “configured but unavailable” may share the same user-facing fallback, but they should not be the same system state. If Postgres is down and the storefront falls back to a static catalog, the site may stay browsable while serving stale price, availability, or activation data. That needs explicit degraded-mode telemetry, alerts, and probably visible freshness metadata where correctness matters. The other challenge is the configuration matrix. Four independent integrations already create many possible combinations, so contract tests for each real adapter and a small set of supported capability profiles would help prevent the demo path from becoming more reliable than production. The core principle is excellent: detect capabilities independently and define an honest fallback for each one—where “honest” sometimes means refusing.