← Back to blog
·11 min read

Build a Stripe Subscription SaaS with Next.js in a Weekend (Then Sell It)

Next.jsStripeSaaSClaude CodePassive IncomeSell CodeTypeScript
Build a Stripe Subscription SaaS with Next.js in a Weekend (Then Sell It)

Why Stripe Subscriptions Are the #1 Boilerplate Problem

Every developer building a SaaS hits the same wall: Stripe subscriptions are genuinely complicated to wire up correctly.

It's not just a checkout form. You need:

  • Checkout sessions that create customers
  • Webhook handlers for 6+ event types
  • Database sync between Stripe state and your own records
  • Customer Portal for plan changes and cancellations
  • Protected routes that check subscription status
  • Edge cases: failed payments, dunning, upgrade/downgrade mid-cycle
  • Most developers spend 2–4 weeks getting this right. That's the opportunity. Build it once, package it as a sellable template, and let other developers pay to skip the pain.

    This guide shows you how to build the entire thing in a weekend using Claude Code — and how to list it on CodeCudos to generate passive income.

    Stripe subscription SaaS dashboard

    Stripe subscription SaaS dashboard

    ---

    What We're Building

    A production-ready Next.js SaaS boilerplate with:

  • Auth — NextAuth.js with Google + GitHub OAuth + email/password
  • Stripe Subscriptions — monthly/annual plans, Checkout Session, Customer Portal
  • Database — PostgreSQL + Prisma with User, Account, Subscription models
  • Protected Dashboard — subscription-gated routes with middleware
  • Billing Page — plan display, upgrade/downgrade, cancel flow
  • Webhook Handler — all critical Stripe events handled correctly
  • Email — transactional emails via Resend
  • Landing Page — pricing section that drives to Stripe Checkout
  • This is exactly what sells at $99–$199 on code marketplaces. Buyers pay to skip the setup; you earn every time.

    ---

    Phase 1: Scaffold with Claude Code (Friday Evening, ~3 hours)

    Install Claude Code

    bash
    npm install -g @anthropic-ai/claude-code

    Initialize the Project

    bash
    mkdir stripe-saas-starter && cd stripe-saas-starter
    git init
    claude

    The Foundation Prompt

    Paste this into your Claude Code session:

    Build a Next.js 14 SaaS boilerplate using the App Router with the following stack:
    - TypeScript throughout
    - Tailwind CSS + shadcn/ui components
    - NextAuth.js v5 (auth.js) with Google, GitHub, and credentials providers
    - Prisma ORM with PostgreSQL
    - Stripe for subscription billing (monthly + annual plans)
    - Resend for transactional email
    - Zod for validation
    
    Create these routes and pages:
    1. Landing page (/) with hero, features, and pricing section
    2. Sign in (/auth/signin) and sign up (/auth/signup)
    3. Dashboard (/dashboard) — protected, requires active subscription
    4. Billing (/dashboard/billing) — manage subscription, upgrade, cancel
    5. Settings (/dashboard/settings) — user profile settings
    6. API webhook handler (/api/webhooks/stripe) for Stripe events
    
    Prisma schema should include: User, Account (NextAuth), Session, Subscription, Price models.
    
    The Stripe integration should handle these webhook events:
    - checkout.session.completed
    - customer.subscription.created
    - customer.subscription.updated
    - customer.subscription.deleted
    - invoice.payment_failed
    
    Add middleware that protects /dashboard/* routes — redirect to /pricing if no active subscription.
    
    Include a .env.example with all required environment variables.

    Let Claude Code run. It will scaffold the full project, install dependencies, and write all the files. This takes 10–20 minutes.

    ---

    Phase 2: The Stripe Integration in Detail

    Claude Code will generate the core, but here's what a correct webhook handler looks like — and what distinguishes a production-quality boilerplate from a tutorial project.

    The Webhook Handler

    typescript
    // app/api/webhooks/stripe/route.ts
    import { headers } from "next/headers";
    import { NextResponse } from "next/server";
    import Stripe from "stripe";
    import { stripe } from "@/lib/stripe";
    import { prisma } from "@/lib/prisma";
    
    const relevantEvents = new Set([
      "checkout.session.completed",
      "customer.subscription.created",
      "customer.subscription.updated",
      "customer.subscription.deleted",
      "invoice.payment_failed",
    ]);
    
    export async function POST(req: Request) {
      const body = await req.text();
      const sig = headers().get("stripe-signature");
      const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
    
      let event: Stripe.Event;
    
      try {
        event = stripe.webhooks.constructEvent(body, sig!, webhookSecret);
      } catch (err) {
        const message = err instanceof Error ? err.message : "Unknown error";
        console.error(`Webhook signature verification failed: ${message}`);
        return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
      }
    
      if (!relevantEvents.has(event.type)) {
        return NextResponse.json({ received: true });
      }
    
      try {
        switch (event.type) {
          case "checkout.session.completed": {
            const session = event.data.object as Stripe.Checkout.Session;
            await handleCheckoutCompleted(session);
            break;
          }
          case "customer.subscription.created":
          case "customer.subscription.updated": {
            const subscription = event.data.object as Stripe.Subscription;
            await upsertSubscription(subscription);
            break;
          }
          case "customer.subscription.deleted": {
            const subscription = event.data.object as Stripe.Subscription;
            await prisma.subscription.update({
              where: { stripeSubscriptionId: subscription.id },
              data: { status: "canceled", canceledAt: new Date() },
            });
            break;
          }
          case "invoice.payment_failed": {
            const invoice = event.data.object as Stripe.Invoice;
            // Notify user via email, update subscription status
            await handlePaymentFailed(invoice);
            break;
          }
        }
      } catch (error) {
        console.error("Webhook handler error:", error);
        return NextResponse.json(
          { error: "Webhook handler failed" },
          { status: 500 }
        );
      }
    
      return NextResponse.json({ received: true });
    }
    
    async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
      const userId = session.metadata?.userId;
      if (!userId || !session.subscription) return;
    
      const subscription = await stripe.subscriptions.retrieve(
        session.subscription as string
      );
    
      await prisma.subscription.upsert({
        where: { userId },
        create: {
          userId,
          stripeCustomerId: session.customer as string,
          stripeSubscriptionId: subscription.id,
          stripePriceId: subscription.items.data[0].price.id,
          status: subscription.status,
          currentPeriodEnd: new Date(subscription.current_period_end * 1000),
        },
        update: {
          stripeCustomerId: session.customer as string,
          stripeSubscriptionId: subscription.id,
          stripePriceId: subscription.items.data[0].price.id,
          status: subscription.status,
          currentPeriodEnd: new Date(subscription.current_period_end * 1000),
        },
      });
    }
    
    async function upsertSubscription(subscription: Stripe.Subscription) {
      const existing = await prisma.subscription.findUnique({
        where: { stripeSubscriptionId: subscription.id },
      });
    
      if (!existing) return; // Only update subscriptions we know about
    
      await prisma.subscription.update({
        where: { stripeSubscriptionId: subscription.id },
        data: {
          stripePriceId: subscription.items.data[0].price.id,
          status: subscription.status,
          currentPeriodEnd: new Date(subscription.current_period_end * 1000),
          cancelAtPeriodEnd: subscription.cancel_at_period_end,
        },
      });
    }
    
    async function handlePaymentFailed(invoice: Stripe.Invoice) {
      if (!invoice.subscription) return;
    
      await prisma.subscription.updateMany({
        where: { stripeSubscriptionId: invoice.subscription as string },
        data: { status: "past_due" },
      });
    
      // TODO: send payment failed email via Resend
    }

    This is the difference between a tutorial and a sellable product. The webhook handler:

  • Verifies the Stripe signature (security)
  • Only processes events we care about
  • Handles all subscription lifecycle events
  • Syncs to the database correctly
  • Handles payment failures with status updates
  • The Prisma Schema

    prisma
    // prisma/schema.prisma
    model User {
      id            String   @id @default(cuid())
      name          String?
      email         String   @unique
      emailVerified DateTime?
      image         String?
      password      String?
      createdAt     DateTime @default(now())
      updatedAt     DateTime @updatedAt
    
      accounts     Account[]
      sessions     Session[]
      subscription Subscription?
    }
    
    model Subscription {
      id                   String    @id @default(cuid())
      userId               String    @unique
      stripeCustomerId     String    @unique
      stripeSubscriptionId String    @unique
      stripePriceId        String
      status               String    // active, trialing, past_due, canceled, etc.
      currentPeriodEnd     DateTime
      cancelAtPeriodEnd    Boolean   @default(false)
      canceledAt           DateTime?
      createdAt            DateTime  @default(now())
      updatedAt            DateTime  @updatedAt
    
      user User @relation(fields: [userId], references: [id], onDelete: Cascade)
    }

    The Checkout Flow

    typescript
    // app/api/checkout/route.ts
    import { auth } from "@/auth";
    import { stripe } from "@/lib/stripe";
    import { NextResponse } from "next/server";
    
    export async function POST(req: Request) {
      const session = await auth();
      if (!session?.user?.id) {
        return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
      }
    
      const { priceId } = await req.json();
    
      const checkoutSession = await stripe.checkout.sessions.create({
        mode: "subscription",
        payment_method_types: ["card"],
        line_items: [{ price: priceId, quantity: 1 }],
        success_url: `${process.env.NEXT_PUBLIC_URL}/dashboard?success=true`,
        cancel_url: `${process.env.NEXT_PUBLIC_URL}/pricing?canceled=true`,
        metadata: { userId: session.user.id },
        subscription_data: {
          metadata: { userId: session.user.id },
        },
        allow_promotion_codes: true,
      });
    
      return NextResponse.json({ url: checkoutSession.url });
    }

    The Subscription Middleware

    typescript
    // middleware.ts
    import { auth } from "@/auth";
    import { NextResponse } from "next/server";
    import type { NextRequest } from "next/server";
    
    export async function middleware(request: NextRequest) {
      const session = await auth();
    
      // Redirect unauthenticated users to sign in
      if (!session?.user) {
        return NextResponse.redirect(new URL("/auth/signin", request.url));
      }
    
      // Check subscription for /dashboard/* routes
      if (request.nextUrl.pathname.startsWith("/dashboard")) {
        const sub = session.user.subscription;
    
        const isActive =
          sub?.status === "active" || sub?.status === "trialing";
    
        if (!isActive) {
          return NextResponse.redirect(new URL("/pricing", request.url));
        }
      }
    
      return NextResponse.next();
    }
    
    export const config = {
      matcher: ["/dashboard/:path*"],
    };

    ---

    Phase 3: Polish With Claude Code (Saturday Morning, ~2 hours)

    > Review the project for production readiness. Check:
      - All TypeScript errors
      - Missing error boundaries
      - Any console.log left in the code
      - Missing loading states
      - Accessible form elements (labels, aria attributes)
    
    > Add a comprehensive README with:
      - One-line description
      - Screenshot placeholder section
      - Prerequisites (Node 18+, PostgreSQL, Stripe account)
      - Step-by-step setup instructions (env vars, DB setup, Stripe webhooks)
      - Feature list with checkboxes
      - Tech stack section
      - Deploy to Vercel section
    
    > Run ESLint and fix all warnings. Add Prettier config.
    > Make sure npm run build passes cleanly.

    ---

    Phase 4: Test Everything (Saturday Afternoon, ~2 hours)

    Before listing, test every flow:

    Local Testing Checklist

    bash
    # Start local Stripe webhook forwarding
    stripe listen --forward-to localhost:3000/api/webhooks/stripe
    
    # In another terminal
    npm run dev

    Auth flows:

    Sign up with email/password
    Sign in with Google OAuth (requires Google console setup)
    Sign in with GitHub OAuth
    Sign out

    Stripe flows:

    Click "Get Started" on pricing page → Stripe Checkout opens
    Complete checkout with test card 4242 4242 4242 4242
    Get redirected to /dashboard after success
    Dashboard shows active subscription details
    Click "Manage Subscription" → Stripe Customer Portal opens
    Cancel subscription → dashboard redirects to /pricing after period ends
    Test failed payment with card 4000 0000 0000 0341

    Edge cases:

    Try to access /dashboard without subscription → redirects to /pricing
    Try to access /dashboard while logged out → redirects to /auth/signin

    ---

    Phase 5: Screenshots (Saturday Evening, ~30 min)

    Screenshots are the #1 conversion factor for code listings. Take these:

  • Landing page with the pricing section visible
  • Stripe Checkout in progress (use test mode)
  • Dashboard with subscription status showing "Active"
  • Billing page with plan details and Customer Portal button
  • Mobile view of the dashboard (screenshot at 390px width)
  • Listings with 4+ screenshots convert 3x better than listings with 1–2.

    ---

    Phase 6: Publish to GitHub and List on CodeCudos (Sunday)

    Push to GitHub

    bash
    gh repo create stripe-saas-starter --public --source=. --push

    Quality Score Check

    Before listing, ask Claude Code to run a quick quality audit:

    > Run a pre-listing quality audit. Check:
      - ESLint passes with no warnings
      - TypeScript compiles without errors (tsc --noEmit)
      - .env is in .gitignore, .env.example exists with all required vars
      - No hardcoded secrets or API keys in any file
      - README is complete with setup instructions
      - package.json has correct scripts (dev, build, test)
      - Dependencies are up to date (npm outdated)

    Fix any issues Claude Code finds. Then list:

  • Go to codecudos.com/sell/new
  • Title: "Next.js SaaS Starter — Stripe Subscriptions, Auth, PostgreSQL"
  • Description: Lead with the problem it solves:
  • > Skip 3 weeks of Stripe boilerplate. This Next.js 14 SaaS starter ships with working subscription billing, Google/GitHub OAuth, Prisma schema, webhook handling, and a protected dashboard. Tested end-to-end. Deploy to Vercel in 15 minutes.

  • Price: $99–$149 (this solves a $2,000+ problem)
  • Tags: Next.js, Stripe, SaaS, TypeScript, Boilerplate
  • Check "Vibe Coded" if you used Claude Code
  • Add all 5 screenshots
  • Paste GitHub repo URL
  • Publish
  • CodeCudos automatically analyzes the repo and assigns a quality score. An A or B score increases placement in search results and dramatically improves conversion.

    ---

    What a Quality Score Measures

    CodeCudos grades every listing on:

    CategoryWhat's CheckedYour Score Impact
    **Lint**ESLint passing, no warningsHigh
    **Security**No hardcoded secrets, safe patternsCritical
    **Dependencies**Up to date, no critical CVEsHigh
    **Tests**Test files present, coverageMedium
    **Documentation**README completeness, .env.exampleHigh

    For a Stripe boilerplate, the bar is higher than a simple template — buyers are trusting your auth and payment code with real money. An A score builds that trust before they buy.

    ---

    Pricing Strategy for Stripe Boilerplates

    What's IncludedSuggested Price
    Auth + Stripe one-time payments$49–$79
    Auth + Stripe subscriptions + basic dashboard$79–$119
    Auth + Stripe subscriptions + full dashboard + email$99–$149
    Everything above + teams/orgs + admin panel$149–$299

    Don't underprice because AI helped build it. Price based on the time saved. A working Stripe subscription integration saves most developers 15–25 hours. At $100/hr, that's $1,500–$2,500 of work. $149 is a bargain.

    ---

    The Revenue Potential

    Real numbers from similar products on code marketplaces:

  • A Next.js + Stripe boilerplate listed at $99 with a B+ quality score and 4 screenshots gets 4–12 sales/month organically from marketplace discovery
  • That's $396–$1,188/month from one product, with zero ongoing maintenance
  • After your first 5 reviews, sales velocity typically doubles
  • The first listing is the hardest. After that, your seller reputation compounds every subsequent listing.

    ---

    FAQ: Common Questions Before Listing

    "Can I sell something built with AI?"

    Yes. Buyers care that it works, not how you built it. CodeCudos has a "Vibe Coded" badge — checking it actually increases discoverability with buyers specifically looking for AI-built templates.

    "What if someone finds a bug?"

    Fix it and push an update. Buyers get access to the same repo. Good bug response builds reviews, and reviews build more sales.

    "How much maintenance is required?"

    Stripe and NextAuth occasionally release breaking changes. Plan to update dependencies every 2–3 months. 1–2 hours/month max.

    "Do I need to support buyers?"

    Responsive sellers earn better reviews and more repeat buyers. Block out 15 minutes/week to answer questions. Most questions are about Stripe webhook setup and env vars — answer them once in the README and they stop coming.

    ---

    Start This Weekend

    You now have everything you need:

  • The exact Claude Code prompt to scaffold the project
  • Production-quality webhook handler code
  • The Prisma schema
  • The testing checklist
  • The listing strategy
  • Build it Friday evening. Polish and test Saturday. Publish Sunday.

    Start selling on CodeCudos →

    Browse Quality-Scored Code

    Every listing on CodeCudos is analyzed for code quality, security, and documentation. Find production-ready components, templates, and apps.

    Browse Marketplace →