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:
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
---
What We're Building
A production-ready Next.js SaaS boilerplate with:
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
npm install -g @anthropic-ai/claude-codeInitialize the Project
mkdir stripe-saas-starter && cd stripe-saas-starter
git init
claudeThe 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
// 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:
The Prisma Schema
// 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
// 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
// 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
# Start local Stripe webhook forwarding
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# In another terminal
npm run devAuth flows:
Stripe flows:
4242 4242 4242 42424000 0000 0000 0341Edge cases:
---
Phase 5: Screenshots (Saturday Evening, ~30 min)
Screenshots are the #1 conversion factor for code listings. Take these:
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
gh repo create stripe-saas-starter --public --source=. --pushQuality 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:
> 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.
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:
| Category | What's Checked | Your Score Impact |
|---|---|---|
| **Lint** | ESLint passing, no warnings | High |
| **Security** | No hardcoded secrets, safe patterns | Critical |
| **Dependencies** | Up to date, no critical CVEs | High |
| **Tests** | Test files present, coverage | Medium |
| **Documentation** | README completeness, .env.example | High |
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 Included | Suggested 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:
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:
Build it Friday evening. Polish and test Saturday. Publish Sunday.