From a Lovable/Bolt mockup to a real backend: what has to be true before you charge
The prototype-to-production gap, concretely. The invisible 80% a Lovable or Bolt mockup skips — idempotent billing, revocable sessions, tenant isolation, server-side secrets — and the exact code that closes it before you take money.
The mockup is the easy part. Lovable or Bolt gets you to a screen that looks like a product in an afternoon — the layout, the fake data, the checkout button that flashes a success toast. Then you want to charge real cards, and the prototype-to-production gap opens under your feet. The demo you shipped is maybe 20% of the code a real backend needs; the other 80% is invisible in a screenshot and it's exactly the part the generator skipped: the webhook that fires when a subscription lapses, the retry when a payment fails, the session that has to die when a password is reset, the query that must not return another customer's rows.
That 80/20 cliff is the single most-repeated complaint from people crossing this bridge. The AI takes you to a working-looking app at a speed that feels "almost unfair," and then the last stretch — auth, security, edge cases, real billing, deploy — "eats more time than building everything from scratch" (as of July 2026, mindstudio.ai). It's not a skill gap. It's that the demo and the system share almost no code.
This post is the checklist: what has to be true in the backend before you put a charge screen in front of a stranger. It's written from the actual boilerplate code, not a pitch. And it's a graduation destination, not a Lovable competitor — if your app never needs owned backend logic, stay in no-code. This is for the moment you've decided it does.
"Charge a card" is a prompt. Billing is a system.
Charging a card on the happy path really is one prompt, and it really works in the demo. A production billing system is a different animal: the webhook that lands when a subscription renews, the retry logic when a card declines, the customer portal, the database state that has to reconcile after the webhook arrives, and the confirmation email. The framing that survives contact with production: the happy path is a prompt, the system is not (as of July 2026, buildthisnow.com).
The specific bug that same source names is the one that quietly double-charges
people: webhook handlers that aren't idempotent. "If Stripe sends the same
message twice (which it does), your app charges or credits the user twice." Every
provider redelivers. If your handler does an INSERT on each delivery, a
redelivered invoice.paid bills the customer again.
The boilerplate's webhook path is verify-then-translate. The provider adapter
validates the signature and normalizes the raw body into a BillingEvent; the
use case re-publishes it as a domain event and acks — it never touches the
database directly:
async execute(req: WebhookRequest): Promise<Result<HandleWebhookOutput, DomainError>> {
const parsed = await this.provider.parseWebhook(req); // verifies signature + parses
if (!parsed.ok) return parsed;
if (!parsed.value) return ok({ accepted: true }); // valid, but a type we don't model yet
const event = parsed.value;
await this.events.publish([
{ name: `billing.${event.type}`, aggregateId, occurredAt: new Date(), payload: event },
]);
return ok({ accepted: true, eventType: event.type });
}The idempotency lives one layer down, in the listeners that reduce those events into the read model. Redelivery is expected, so the writes are upserts keyed on the provider's own id, not inserts:
bus.subscribe('billing.subscription.created', async (event) => {
const p = event.payload as Record<string, unknown>;
await subs.upsert({
customerInternalId: asString(p, 'customerInternalId'),
provider: asString(p, 'provider'),
externalId: asString(p, 'subscriptionId'), // the idempotency key
priceId: asString(p, 'priceId'),
status: 'active',
currentPeriodEnd: asDate(p, 'currentPeriodEnd'),
metadata: (p.metadata as Record<string, unknown> | undefined) ?? null,
});
});One-shot purchases follow the same rule. RecordPurchaseUseCase is documented
as "Idempotent on externalOrderId — webhook deliveries are at-least-once
across every provider," and the repository backs that with an atomic upsert:
const row = await this.prisma.purchase.upsert({
where: { externalOrderId: input.externalOrderId }, // unique per provider invoice
update: { /* ...refresh the row */ },
create: { externalOrderId: input.externalOrderId, /* ...persist */ },
});The net effect: the same webhook delivered five times produces one subscription
row and one purchase row. That's the difference between a demo checkout and a
billing system — and it's the same shape whether the money came through Stripe,
MercadoPago, or Polar, because the providers all normalize into one
BillingEvent.
A session has to be revocable
The most-cited "vibe-coded auth" defect is deceptively small: sessions that don't log out on a password change. You reset the password after a breach, and the attacker's old login token still works. From a blank repo, an agent averages years of public code — and "the average includes the bugs" (as of July 2026, buildthisnow.com). Session invalidation is one of the three it names.
Revoking a session is trivial if the primitive exists. In the boilerplate it's one first-class port with one obvious implementation — delete the BetterAuth session rows for a user:
async revokeAllForUser(userId: string): Promise<number> {
const result = await this.prisma.session.deleteMany({ where: { userId } });
return result.count;
}What makes it production-grade isn't the delete — it's that it's wired into the trust-and-safety operations and audited. Force-logout, force-password-reset, suspend, and soft-delete all call it, and every call emits an audit entry:
const revoked = await this.sessions.revokeAllForUser(input.targetUserId);
await this.audit.execute({
action: 'platform.user.force_logout',
actorUserId: input.actorUserId,
resourceType: 'user',
resourceId: input.targetUserId,
payload: { revoked },
});One honest note, because "state completeness truthfully" beats overselling: the
user-facing change-password call currently passes revokeOtherSessions: false
to BetterAuth, so by default changing your own password doesn't nuke your other
sessions. The primitive is there, tested, and admin-wired; making a self-service
password change also revoke is a one-line flag flip, not a feature you have to
build. The point of a base is that the knob exists and you decide the policy —
not that you discover the knob is missing after an incident.
One customer must not read another's rows
If your app has organizations or workspaces, the boring, catastrophic failure is
a findMany that forgot its where: { organizationId }. It typechecks, passes
review, ships — and then org A sees org B's data. This isn't hypothetical at the
vibe-coding tier: a scan of 1,645 Lovable apps found 170+ with a fully exposed
database and no row-level security at all (as of July 2026,
vibe-eval.com).
The non-technical builder doesn't know RLS exists to turn on.
The boilerplate closes that category with a Prisma query guard that throws on a tenant-scoped read that forgot its filter, instead of quietly returning every tenant's rows. It's the subject of its own deep-dive — the mechanics, the allowlist, and what it deliberately doesn't cover are in Multi-tenancy in Prisma without RLS — but the one-line version is: a forgotten tenant filter becomes a stack trace in your test run, not a data leak in a support ticket.
Secrets live on the server, validated at boot
The canonical vibe-coding horror story is a Stripe secret key shipped in the frontend bundle. One real, widely-cited version: a builder's "user list was leaked on the FE of the code," a hacker "got a hold of his Stripe key and issued every customer a refund," and now he's "hiring a developer to shore it up" (as of July 2026, Hacker News). Across audited Lovable apps, roughly 90% had hardcoded API keys visible in the browser bundle (vibe-eval.com).
The structural fix is not vigilance, it's architecture: secrets are server-only env vars, validated at boot by a Zod schema, and the server never hands them to the client. If a required one is missing, the process refuses to start rather than booting into a half-configured state:
BETTER_AUTH_SECRET: z.string().min(32),
STRIPE_SECRET_KEY: z.string().optional(),
STRIPE_WEBHOOK_SECRET: z.string().optional(),
MERCADO_PAGO_WEBHOOK_SECRET: z.string().optional(),The webhook secrets are what the provider adapters use to verify incoming signatures — the first line of the billing flow above. They're read on the server, never serialized into a page, and there's no code path that ships them to the browser because the frontend never sees them in the first place.
The checklist, before you take money
None of this is exotic. It's the specific set of things that are invisible in a mockup and non-negotiable in a product:
- Billing is idempotent. Webhooks are verified, and redelivery of the same event upserts one row instead of charging twice.
- Sessions are revocable and audited. A compromised account can be force-logged-out; a password reset can invalidate old tokens; every such action leaves a trail.
- Tenants are isolated by construction. A forgotten
organizationIdfilter fails loud, it doesn't leak. - Secrets are server-side and boot-validated. No key ever reaches the browser bundle; a missing required secret stops the boot.
The objection worth answering head-on: skeptics say most starter kits end in a rewrite anyway — "don't do it unless you're prepared to redo everything from the base later." That's a fair critique of a spaghetti kit. It's the reason this base leans on compiler-enforced boundaries (lint-enforced DDD layering, typed OpenAPI contracts, tests): the parts you'd normally throw away are the parts the architecture stops you from tangling in the first place. It's meant to be the base you scale, not the scaffold you discard.
If you're at exactly this point — the mockup works, and you can feel the last 20% looming before you can charge — the from-mockup-to-production guide lays out the full crossing, and why this boilerplate exists explains the bet underneath it. When you're ready to run the code these snippets came from, the getting-started docs take you from clone to a booting, guarded backend. The boring, dangerous parts are already built and locked down — which is the whole point of not shipping them yourself.