The Part of a SaaS MVP Nobody Scaffolds Correctly
Ask most AI coding assistants to "add Stripe billing" and you'll get a checkout.session.completed handler in under a minute. What you won't get, unless you ask for it explicitly, is protection against Stripe calling that same handler two or three times for the same event. Stripe's own retry policy resends a webhook if your server doesn't respond within a few seconds, and under real traffic, a cold serverless function, a slow database round trip, that happens more often than teams expect. Without an idempotency check, customer.subscription.deleted firing twice against a naive handler can mark an active subscription canceled, then canceled again, corrupting the state your billing UI reads from.
The saas-mvp-scaffolder skill generates that surrounding layer deliberately, as a five-phase workflow: entity modeling, App Router directory scaffolding, the idempotent Stripe pipeline, auth middleware, and deployment configs, built for Next.js 15, Supabase or Postgres, and Prisma or Drizzle. If you're trying to scaffold a SaaS MVP with AI agents instead of assembling it feature by feature, this is the order the phases actually run in.
1. Entity Modeling Before You Scaffold a SaaS MVP with AI Agents
Phase 1 of the workflow starts with a question most scaffolding tools skip: is this B2B multi-tenant or B2C single-user, and is billing seat-based or credit-based? That answer changes the schema. A multi-tenant seat model needs an Organization entity that owns the Subscription, with User records belonging to an org rather than holding their own billing state. A credit-based B2C model doesn't need organizations at all, it needs a usage ledger.
The skill generates the Prisma or Drizzle schema against four core entities: User, Organization, Subscription, and ProcessedEvent. That last one is easy to forget when you're scaffolding by hand, and it's the table the entire idempotency guarantee depends on.
2. The Next.js 15 App Router Layout
Phase 2 scaffolds a directory structure using route groups to keep public, authenticated, and API surfaces separate:
src/
βββ app/
β βββ (auth)/login/page.tsx
β βββ (dashboard)/dashboard/page.tsx
β βββ (dashboard)/billing/page.tsx
β βββ api/webhooks/stripe/route.ts
β βββ layout.tsx
βββ components/ui/
βββ lib/
β βββ stripe.ts
β βββ db.ts
β βββ auth.ts
βββ middleware.ts
The parenthesized folders, (auth) and (dashboard), are Next.js route groups. They let /login and /dashboard sit at the same URL depth while giving each its own layout, without leaking a shared nav bar onto the login screen. middleware.ts at the root is what actually enforces the boundary between them; the folder structure alone doesn't block anything.
3. The Idempotent Stripe Webhook, Line by Line
This is Phase 3, and it's the part worth reading in full rather than skimming. A findUnique existence check alone isn't idempotency: two concurrent deliveries of the same event can both read "not found" before either one writes its row, and both then run the business mutation. The fix the skill generates is to claim the event row atomically, via a unique-constrained insert, before any business effect runs, so the database itself rejects the second concurrent attempt:
// api/webhooks/stripe/route.ts
import { headers } from "next/headers";
import { Prisma } from "@prisma/client";
import { stripe } from "@/lib/stripe";
import { db } from "@/lib/db";
export async function POST(req: Request) {
const body = await req.text();
const signature = (await headers()).get("Stripe-Signature");
if (!signature) return new Response("Missing signature", { status: 400 });
let event;
try {
event = stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!);
} catch (err: any) {
return new Response(`Webhook Signature Error: ${err.message}`, { status: 400 });
}
// 1. Atomically CLAIM this event before any business effect runs. The unique
// constraint on eventId means a concurrent duplicate delivery's insert
// fails here with P2002, it never reaches the mutation below.
try {
await db.processedEvent.create({
data: { eventId: event.id, eventType: event.type, status: "processing" }
});
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002") {
// Already claimed by this delivery or a concurrent one, acknowledge
// as a successful duplicate, don't re-run the mutation.
return new Response(JSON.stringify({ received: true, duplicate: true }), { status: 200 });
}
throw err;
}
// 2. Only the request that won the claim above reaches here. Process the
// business mutation inside a transaction.
try {
await db.$transaction(async (tx) => {
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object;
const orgId = session.metadata?.orgId;
if (orgId) {
await tx.organization.update({
where: { id: orgId },
data: {
stripeCustomerId: session.customer as string,
stripeSubscriptionId: session.subscription as string,
subscriptionStatus: "active"
}
});
}
break;
}
case "customer.subscription.deleted": {
const sub = event.data.object;
await tx.organization.updateMany({
where: { stripeSubscriptionId: sub.id },
data: { subscriptionStatus: "canceled" }
});
break;
}
}
await tx.processedEvent.update({
where: { eventId: event.id },
data: { status: "completed" }
});
});
} catch (err) {
// Retryable failure: leave status as "processing" rather than deleting the
// claim row, a reprocessing job can pick this up, but a second live
// Stripe delivery must still be rejected as a duplicate, not silently
// reprocessed. Never leak internal error text back to Stripe's caller.
console.error("webhook processing failed, will be retried by reconciliation job", { eventId: event.id, err });
return new Response(JSON.stringify({ received: true, retrying: true }), { status: 200 });
}
return new Response(JSON.stringify({ received: true }), { status: 200 });
}
Two details carry the whole design. First, the claim happens as its own insert, guarded by the unique constraint on eventId, before any business mutation runs, so two concurrent deliveries can't both slip past a check and both mutate billing state; the database's rejection of the second insert (P2002) is what actually enforces idempotency, not an earlier existence check. Second, only the request that wins the claim reaches the $transaction at all, and a failure inside it leaves the claim row's status as "processing" rather than deleting it, so a genuine retry from Stripe still gets picked up by reconciliation instead of either vanishing or reprocessing twice.
4. Auth Middleware and Route Protection
Phase 4 generates edge-compatible session validation covering /dashboard, /settings, and /api/protected/*. This runs in middleware.ts, ahead of the page or route handler, which matters for one reason: it keeps session checks off the client bundle entirely. The skill's core invariant here is explicit, data fetching and secrets stay server-side, and private keys never ship to the browser.
5. Picking a Deployment Target Without Mixing Two Playbooks
Phase 5 is where scaffolds generated by general-purpose AI assistants tend to get sloppy: half a Vercel config next to half a Dockerfile, with environment variables handled inconsistently between the two. The skill treats them as separate outputs.
| Deployment Target | What Gets Generated | Where It Breaks If Mixed |
|---|---|---|
| Vercel | Native Git integration, .env.production sync |
Build assumes Vercel's edge runtime; Docker health checks won't find it |
| Fly.io / AWS ECS / Self-hosted | Multi-stage Dockerfile with standalone Next.js build output |
Standalone output ignores Vercel-specific env injection, so secrets set only in the Vercel dashboard never reach the container |
The rule the skill enforces, don't mix Vercel native build instructions with containerized workflows, sounds obvious written down. It's the thing that actually breaks when an AI assistant pastes Docker instructions from one Stack Overflow answer into a Vercel-deployed project because both showed up in the same search.
How to Scaffold a SaaS MVP with AI Agents in One Prompt
The skill is a single SKILL.md file, copy it into your assistant's skills directory and describe the SaaS you're building in plain language:
"Using the saas-mvp-scaffolder skill, scaffold a complete Next.js 15 B2B SaaS architecture
with Supabase Auth, Prisma schema, and Stripe tier billing."
It's confirmed to load in Claude Code, Cursor, Windsurf, Gemini CLI, Google Antigravity, and OpenHands, and it runs entirely inside your local session, no external network permissions are required to generate the scaffold.
Frequently Asked Questions
What tech stack does the saas-mvp-scaffolder skill generate?
Next.js 15 with the App Router, Server Components, and Server Actions, written in TypeScript with Tailwind CSS, against Supabase or plain PostgreSQL, using either Prisma or Drizzle ORM, with Stripe's billing SDK for subscriptions.
Does it actually prevent duplicate Stripe charges, or just verify webhook signatures?
Both. Signature verification confirms the event came from Stripe; the separate processedEvent idempotency check, run inside the same database transaction as the billing mutation, is what stops a retried event from applying twice.
Can I deploy the generated project to something other than Vercel?
Yes. Phase 5 also outputs a multi-stage Dockerfile with standalone Next.js build output for Fly.io, AWS ECS, or self-hosted containers β generated as a separate config from the Vercel path, not a hybrid of the two.
Comments
Comments are reviewed before appearing publicly.
No comments yet β be the first.