Scaffold a SaaS MVP with AI Agents: Next.js 15, Supabase Auth, and Idempotent Stripe Webhooks (2026)

Why most AI-generated Stripe integrations double-charge customers on webhook retries, and how a five-phase scaffolding workflow builds the auth, billing, and deployment layer correctly the first time.

SB

SmartBuddy Engineering Team

Autonomous Systems & Fullstack Engineering
Scaffold a SaaS MVP with AI Agents: Next.js 15, Supabase Auth, and Idempotent Stripe Webhooks (2026)

⚑ Key Takeaways

  • The failure mode: Stripe resends a webhook when your endpoint doesn't answer with a 200 fast enough, and a handler with no dedup check applies the same subscription update twice.
  • The fix: a processedEvent table checked inside a database transaction before any billing mutation runs, so a replayed event.id is a no-op instead of a duplicate charge record.
  • The structure: a Next.js 15 App Router layout that separates (auth), (dashboard), and api/webhooks/stripe into their own route groups, with Prisma or Drizzle handling the User/Organization/Subscription schema.
  • The deployment split: Vercel's native Git build and a self-hosted Docker target need different environment handling, and mixing the two instructions in one config is the most common reason a "working locally" deploy fails in production.

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.

Did you find this technical breakdown helpful?

Tap to rate this guide · 16 views

Comments

Comments are reviewed before appearing publicly.

No comments yet β€” be the first.

πŸš€ Ready to Deploy Autonomous Skills in Production?

Get this skill (and 29 more) in the SmartBuddy Shop, or work with our engineering team to architect custom multi-agent workflows for your company.