SaaS boilerplate kits sell for $200-$500 on marketplaces. They include authentication, Stripe subscriptions, admin panels, and billing portals. The same starter template, over and over.
Let us build one with Claude in an afternoon.
This is the most complex project in the series so far. Stripe’s webhook lifecycle, subscription state management, role-based access, and usage tracking all need to work together. A lot can go wrong.
Total time: 5 hours 22 minutes. 7 prompts. Stripe webhooks took the most iteration.
What We’re Building
A complete SaaS starter with:
- Landing page with pricing table (3 tiers: Free, Pro $9/mo, Team $29/mo)
- User authentication with NextAuth.js (Google + email/password)
- Stripe Checkout — subscribe to a plan from the pricing page
- User dashboard — subscription status, usage stats, billing info
- Stripe webhooks — handle subscription lifecycle events
- Billing portal — upgrade, downgrade, cancel, update payment method
- Usage tracking — track API calls per user, enforce limits by tier
- Admin panel — user list, subscription stats, revenue chart
- Email notifications using Resend — welcome email, payment failed warning
Tech stack: Next.js 15 (App Router), Stripe, NextAuth.js, PostgreSQL (Supabase), Prisma, Tailwind CSS, shadcn/ui
Prerequisites
- Claude Code installed and configured
- Node.js 18+ installed
- A Stripe account (test mode)
- A Supabase account (free tier)
- A Resend account (free tier — 100 emails/day)
- A Google OAuth client ID (for Google sign-in)
The Build Session
Total time: 5 hours 22 minutes. 7 prompts.
Prompt 1: Project Setup — Auth + Landing Page + Pricing (Minute 0)
I started with a big first prompt. For a SaaS app, the landing page and auth need to work together from the start:
Create a SaaS dashboard with Next.js 15 App Router.
Structure:
1. Landing page at / with:
- Hero section with headline and CTA
- Features grid (6 features)
- Pricing table with 3 tiers:
- Free: 100 API calls/month, 1 project, community support
- Pro ($9/mo): 10,000 API calls/month, unlimited projects, email support
- Team ($29/mo): 100,000 API calls/month, unlimited projects, priority support, team management
- Footer
2. Auth with NextAuth.js:
- Google OAuth provider
- Credentials provider (email + password)
- Session stored in database (Prisma adapter)
- Pages: /auth/login, /auth/register
3. Database (Prisma + PostgreSQL):
- User: id, name, email, image, stripeCustomerId, subscriptionTier (free/pro/team),
subscriptionStatus (active/canceled/past_due), apiCallsUsed, apiCallsLimit, createdAt
- Subscription: id, userId, stripeSubscriptionId, stripePriceId, status, currentPeriodStart,
currentPeriodEnd, cancelAtPeriodEnd
- ApiUsage: id, userId, endpoint, timestamp
4. Styling: Tailwind CSS + shadcn/ui components
- Professional look — dark sidebar, light content area
- Mobile responsive
Use Next.js middleware to protect /dashboard/* routes.
What Claude generated:
The project structure was well-organized:
src/
app/
page.tsx # Landing page
auth/
login/page.tsx
register/page.tsx
dashboard/
page.tsx # User dashboard
billing/page.tsx # Billing management
usage/page.tsx # Usage stats
admin/
page.tsx # Admin panel
api/
auth/[...nextauth]/route.ts
stripe/
checkout/route.ts
webhook/route.ts
portal/route.ts
components/
landing/
Hero.tsx
Features.tsx
PricingTable.tsx
dashboard/
Sidebar.tsx
UsageChart.tsx
SubscriptionCard.tsx
lib/
auth.ts # NextAuth config
stripe.ts # Stripe client
prisma.ts # Prisma client
middleware.ts # Auth protection
The pricing table component:
const PLANS = [
{
name: "Free",
price: "$0",
period: "forever",
features: [
"100 API calls/month",
"1 project",
"Community support",
"Basic analytics",
],
cta: "Get Started",
highlighted: false,
},
{
name: "Pro",
price: "$9",
period: "/month",
features: [
"10,000 API calls/month",
"Unlimited projects",
"Email support",
"Advanced analytics",
"Custom domain",
"Priority queue",
],
cta: "Start Pro Trial",
highlighted: true,
priceId: process.env.NEXT_PUBLIC_STRIPE_PRO_PRICE_ID,
},
{
name: "Team",
price: "$29",
period: "/month",
features: [
"100,000 API calls/month",
"Unlimited projects",
"Priority support",
"Advanced analytics",
"Custom domain",
"Team management",
"SSO",
"Audit logs",
],
cta: "Start Team Trial",
highlighted: false,
priceId: process.env.NEXT_PUBLIC_STRIPE_TEAM_PRICE_ID,
},
];
function PricingTable() {
return (
<section className="py-20 px-4" id="pricing">
<h2 className="text-3xl font-bold text-center mb-4">Simple Pricing</h2>
<p className="text-gray-500 text-center mb-12">
Start free. Upgrade when you need more.
</p>
<div className="max-w-5xl mx-auto grid md:grid-cols-3 gap-8">
{PLANS.map((plan) => (
<div
key={plan.name}
className={`rounded-2xl p-8 ${
plan.highlighted
? "bg-blue-600 text-white ring-2 ring-blue-600 scale-105"
: "bg-white border border-gray-200"
}`}
>
<h3 className="text-xl font-semibold">{plan.name}</h3>
<div className="mt-4 flex items-baseline">
<span className="text-4xl font-bold">{plan.price}</span>
<span className="ml-1 text-sm opacity-70">{plan.period}</span>
</div>
<ul className="mt-6 space-y-3">
{plan.features.map((feature) => (
<li key={feature} className="flex items-center gap-2 text-sm">
<CheckIcon /> {feature}
</li>
))}
</ul>
<button
onClick={() => handleSubscribe(plan.priceId)}
className={`mt-8 w-full py-3 rounded-lg font-medium ${
plan.highlighted
? "bg-white text-blue-600 hover:bg-gray-100"
: "bg-blue-600 text-white hover:bg-blue-700"
}`}
>
{plan.cta}
</button>
</div>
))}
</div>
</section>
);
}
The NextAuth configuration:
export const authOptions: NextAuthOptions = {
adapter: PrismaAdapter(prisma),
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
CredentialsProvider({
name: "credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) return null;
const user = await prisma.user.findUnique({
where: { email: credentials.email },
});
if (!user || !user.passwordHash) return null;
const valid = await bcrypt.compare(credentials.password, user.passwordHash);
if (!valid) return null;
return { id: user.id, name: user.name, email: user.email, image: user.image };
},
}),
],
session: { strategy: "jwt" },
pages: {
signIn: "/auth/login",
},
callbacks: {
async jwt({ token, user }) {
if (user) token.userId = user.id;
return token;
},
async session({ session, token }) {
if (session.user) session.user.id = token.userId as string;
return session;
},
},
};
My review: The landing page looked professional. The pricing table had proper visual hierarchy with the Pro plan highlighted and slightly scaled up. NextAuth with both Google and credentials worked after I set up the environment variables.
Problem: Claude used the Prisma adapter for NextAuth but also used JWT sessions. The Prisma adapter is for database sessions. With JWT sessions, the adapter is used only for user creation on first sign-in, which is fine, but the authorize callback for credentials did not create users — it only looked them up. I needed a separate registration endpoint.
Claude had generated a registration page but forgot the API route. I asked for it in the next prompt.
Time spent: 48 minutes
Prompt 2: Stripe Checkout and Subscription (Minute 48)
Add Stripe subscription checkout:
1. POST /api/stripe/checkout — create a Stripe Checkout Session
- Accept priceId in the body
- Create a Stripe customer if the user does not have one
- Link the Stripe customer to the user in the database
- Redirect to Stripe Checkout with success_url and cancel_url
- Include subscription mode, not payment mode
2. Also add the missing registration API route at /api/auth/register:
- Accept name, email, password
- Hash password with bcrypt
- Create user in database
- Return success
3. After successful checkout, redirect to /dashboard with a success message
4. Show current subscription status on the dashboard:
- Plan name, status, next billing date
- "Manage Subscription" button that opens Stripe Customer Portal
What Claude generated:
The checkout endpoint:
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-12-18.acacia",
});
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { priceId } = await req.json();
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
if (!user) return NextResponse.json({ error: "User not found" }, { status: 404 });
// Create or retrieve Stripe customer
let customerId = user.stripeCustomerId;
if (!customerId) {
const customer = await stripe.customers.create({
email: user.email!,
name: user.name || undefined,
metadata: { userId: user.id },
});
customerId = customer.id;
await prisma.user.update({
where: { id: user.id },
data: { stripeCustomerId: customerId },
});
}
// Create Checkout Session
const checkoutSession = await stripe.checkout.sessions.create({
customer: customerId,
mode: "subscription",
payment_method_types: ["card"],
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?checkout=success`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?checkout=canceled`,
subscription_data: {
metadata: { userId: user.id },
},
});
return NextResponse.json({ url: checkoutSession.url });
}
The Stripe Customer Portal endpoint:
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
if (!user?.stripeCustomerId) {
return NextResponse.json({ error: "No subscription" }, { status: 400 });
}
const portalSession = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard`,
});
return NextResponse.json({ url: portalSession.url });
}
The subscription card on the dashboard:
function SubscriptionCard({ user }: { user: UserWithSubscription }) {
const tier = user.subscriptionTier || "free";
const status = user.subscriptionStatus || "active";
const tierConfig = {
free: { label: "Free", color: "bg-gray-100 text-gray-800" },
pro: { label: "Pro", color: "bg-blue-100 text-blue-800" },
team: { label: "Team", color: "bg-purple-100 text-purple-800" },
};
const config = tierConfig[tier as keyof typeof tierConfig];
return (
<div className="bg-white rounded-xl border p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-lg">Subscription</h3>
<span className={`px-3 py-1 rounded-full text-sm font-medium ${config.color}`}>
{config.label}
</span>
</div>
<div className="space-y-2 text-sm text-gray-600">
<p>Status: <span className="capitalize font-medium text-gray-900">{status}</span></p>
{user.subscription?.currentPeriodEnd && (
<p>
Next billing: {new Date(user.subscription.currentPeriodEnd).toLocaleDateString()}
</p>
)}
<p>
API usage: {user.apiCallsUsed.toLocaleString()} / {user.apiCallsLimit.toLocaleString()}
</p>
</div>
<div className="mt-4 flex gap-2">
{tier === "free" ? (
<Link
href="/#pricing"
className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700"
>
Upgrade
</Link>
) : (
<button
onClick={openBillingPortal}
className="px-4 py-2 bg-gray-100 text-gray-800 rounded-lg text-sm hover:bg-gray-200"
>
Manage Subscription
</button>
)}
</div>
</div>
);
}
My review: Checkout worked in Stripe test mode. I created test products and prices in the Stripe dashboard, added the price IDs to the environment variables, and clicked “Start Pro Trial” on the landing page. It redirected to Stripe Checkout, I used the test card (4242 4242 4242 4242), and I was redirected back to the dashboard.
Problem: After successful checkout, the dashboard still showed “Free” because the database had not been updated yet. Stripe webhooks handle that, and I had not set them up yet. Claude did not mention this gap.
Time spent: 35 minutes
Prompt 3: Stripe Webhooks (Minute 83)
This is where most SaaS projects get complicated. Stripe sends webhook events for subscription lifecycle changes. You need to handle them all correctly.
Add Stripe webhook handler at POST /api/stripe/webhook:
Handle these events:
1. checkout.session.completed — set user tier based on the price ID, create Subscription record
2. customer.subscription.updated — update tier if plan changed, update status, update period dates
3. customer.subscription.deleted — set user back to free tier, mark subscription as canceled
4. invoice.payment_failed — set subscription status to past_due, send warning email
For each event:
- Look up the user by stripeCustomerId
- Update the user's subscriptionTier and subscriptionStatus
- Update the Subscription record
- Log the event for debugging
Map price IDs to tiers:
- STRIPE_PRO_PRICE_ID -> "pro" (apiCallsLimit: 10000)
- STRIPE_TEAM_PRICE_ID -> "team" (apiCallsLimit: 100000)
Use raw body for webhook signature verification.
What Claude generated:
import { headers } from "next/headers";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const PRICE_TO_TIER: Record<string, { tier: string; limit: number }> = {
[process.env.STRIPE_PRO_PRICE_ID!]: { tier: "pro", limit: 10000 },
[process.env.STRIPE_TEAM_PRICE_ID!]: { tier: "team", limit: 100000 },
};
export async function POST(req: NextRequest) {
const body = await req.text();
const headersList = await headers();
const signature = headersList.get("stripe-signature")!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
console.error("Webhook signature verification failed:", err);
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
console.log(`Stripe webhook: ${event.type}`);
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
const userId = session.subscription_data?.metadata?.userId
|| session.metadata?.userId;
if (!userId) {
// Fall back to looking up by customer ID
const user = await prisma.user.findFirst({
where: { stripeCustomerId: session.customer as string },
});
if (!user) break;
}
const subscription = await stripe.subscriptions.retrieve(
session.subscription as string
);
const priceId = subscription.items.data[0]?.price.id;
const tierConfig = PRICE_TO_TIER[priceId];
if (tierConfig) {
const user = await prisma.user.findFirst({
where: { stripeCustomerId: session.customer as string },
});
if (user) {
await prisma.user.update({
where: { id: user.id },
data: {
subscriptionTier: tierConfig.tier,
subscriptionStatus: "active",
apiCallsLimit: tierConfig.limit,
},
});
await prisma.subscription.upsert({
where: { userId: user.id },
create: {
userId: user.id,
stripeSubscriptionId: subscription.id,
stripePriceId: priceId,
status: "active",
currentPeriodStart: new Date(subscription.current_period_start * 1000),
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
},
update: {
stripeSubscriptionId: subscription.id,
stripePriceId: priceId,
status: "active",
currentPeriodStart: new Date(subscription.current_period_start * 1000),
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
},
});
}
}
break;
}
case "customer.subscription.updated": {
const subscription = event.data.object as Stripe.Subscription;
const priceId = subscription.items.data[0]?.price.id;
const tierConfig = PRICE_TO_TIER[priceId];
const user = await prisma.user.findFirst({
where: { stripeCustomerId: subscription.customer as string },
});
if (user && tierConfig) {
const status = subscription.cancel_at_period_end ? "canceling" : subscription.status;
await prisma.user.update({
where: { id: user.id },
data: {
subscriptionTier: tierConfig.tier,
subscriptionStatus: status === "active" ? "active" : status,
apiCallsLimit: tierConfig.limit,
},
});
await prisma.subscription.update({
where: { userId: user.id },
data: {
stripePriceId: priceId,
status,
currentPeriodStart: new Date(subscription.current_period_start * 1000),
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
},
});
}
break;
}
case "customer.subscription.deleted": {
const subscription = event.data.object as Stripe.Subscription;
const user = await prisma.user.findFirst({
where: { stripeCustomerId: subscription.customer as string },
});
if (user) {
await prisma.user.update({
where: { id: user.id },
data: {
subscriptionTier: "free",
subscriptionStatus: "canceled",
apiCallsLimit: 100,
},
});
await prisma.subscription.update({
where: { userId: user.id },
data: { status: "canceled" },
});
}
break;
}
case "invoice.payment_failed": {
const invoice = event.data.object as Stripe.Invoice;
const user = await prisma.user.findFirst({
where: { stripeCustomerId: invoice.customer as string },
});
if (user) {
await prisma.user.update({
where: { id: user.id },
data: { subscriptionStatus: "past_due" },
});
// Send warning email (implemented in Prompt 6)
await sendPaymentFailedEmail(user.email!, user.name || "User");
}
break;
}
}
return NextResponse.json({ received: true });
}
My review: The webhook handler covered the four critical events. The checkout.session.completed handler was the most complex because it needed to retrieve the subscription to get the price ID.
Problem 1: The subscription_data.metadata is not available on the checkout session object after completion. The metadata I set in the checkout creation was on the subscription, not the session. I needed to retrieve the subscription first, then check its metadata.
Problem 2: Next.js App Router automatically parses the request body as JSON, but Stripe webhook verification needs the raw body. Claude used req.text() which was correct for App Router. But the route config needed export const runtime = "nodejs" and the body needed to not be consumed before verification.
I added this to the top of the route:
export const runtime = "nodejs";
Problem 3: Testing webhooks locally required the Stripe CLI. Claude did not mention this. I used:
stripe listen --forward-to localhost:3000/api/stripe/webhook
This gave me a webhook signing secret for local testing.
After these fixes, webhooks worked. I tested the full flow: sign up, subscribe to Pro, verify the dashboard updated, open billing portal, cancel subscription, verify downgrade to Free.
Time spent: 52 minutes
Prompt 4: Usage Tracking and Limits (Minute 135)
Add API usage tracking:
1. Middleware that runs on /api/* routes (except auth and stripe):
- Check the user's apiCallsUsed vs apiCallsLimit
- If over limit, return 429 with { error: "API limit exceeded", limit, used, tier }
- If under limit, increment apiCallsUsed and log to ApiUsage table
- Reset apiCallsUsed to 0 at the start of each billing period
2. Usage stats page at /dashboard/usage:
- Current usage bar (used/limit with percentage)
- Daily usage chart for the last 30 days
- Top endpoints table
- "Upgrade" button if usage is over 80%
3. Add a sample API endpoint GET /api/data that returns mock data
— this lets users test their API limits
What Claude generated:
The usage middleware:
export async function withUsageTracking(
req: NextRequest,
handler: () => Promise<NextResponse>
) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
if (!user) return NextResponse.json({ error: "User not found" }, { status: 404 });
// Check limit
if (user.apiCallsUsed >= user.apiCallsLimit) {
return NextResponse.json(
{
error: "API limit exceeded",
limit: user.apiCallsLimit,
used: user.apiCallsUsed,
tier: user.subscriptionTier,
upgrade_url: "/dashboard/billing",
},
{ status: 429 }
);
}
// Increment usage
await prisma.$transaction([
prisma.user.update({
where: { id: user.id },
data: { apiCallsUsed: { increment: 1 } },
}),
prisma.apiUsage.create({
data: {
userId: user.id,
endpoint: req.nextUrl.pathname,
},
}),
]);
return handler();
}
The usage dashboard:
async function UsagePage() {
const session = await getServerSession(authOptions);
const user = await prisma.user.findUnique({
where: { id: session!.user!.id },
include: {
subscription: true,
},
});
const dailyUsage = await prisma.apiUsage.groupBy({
by: ["timestamp"],
where: {
userId: session!.user!.id,
timestamp: { gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) },
},
_count: true,
});
const topEndpoints = await prisma.apiUsage.groupBy({
by: ["endpoint"],
where: { userId: session!.user!.id },
_count: true,
orderBy: { _count: { endpoint: "desc" } },
take: 10,
});
const percentage = Math.round((user!.apiCallsUsed / user!.apiCallsLimit) * 100);
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">API Usage</h1>
{/* Usage bar */}
<div className="bg-white rounded-xl border p-6">
<div className="flex justify-between text-sm mb-2">
<span>{user!.apiCallsUsed.toLocaleString()} calls used</span>
<span>{user!.apiCallsLimit.toLocaleString()} limit</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-3">
<div
className={`h-3 rounded-full ${
percentage > 90 ? "bg-red-500" : percentage > 70 ? "bg-yellow-500" : "bg-blue-500"
}`}
style={{ width: `${Math.min(percentage, 100)}%` }}
/>
</div>
{percentage > 80 && (
<p className="text-sm text-orange-600 mt-2">
You are using {percentage}% of your limit.{" "}
<Link href="/#pricing" className="text-blue-600 underline">
Upgrade your plan
</Link>
</p>
)}
</div>
{/* Daily chart and top endpoints... */}
</div>
);
}
My review: The usage tracking worked. The transaction ensured that incrementing the count and logging the usage were atomic. The 429 response included all the information a developer would need.
Problem: The groupBy query for daily usage did not group by date — it grouped by the full timestamp, which meant every API call was its own group. I needed to extract the date part. Prisma does not have a built-in DATE() function in groupBy, so I used a raw query:
const dailyUsage = await prisma.$queryRaw`
SELECT DATE("timestamp") as date, COUNT(*) as count
FROM "ApiUsage"
WHERE "userId" = ${session!.user!.id}
AND "timestamp" > NOW() - INTERVAL '30 days'
GROUP BY DATE("timestamp")
ORDER BY date
`;
Not ideal, but Prisma’s groupBy does not support date extraction.
Time spent: 40 minutes
Prompt 5: Admin Panel (Minute 175)
Add admin panel at /dashboard/admin:
1. Only accessible to users with role "admin" (add role field to User model)
2. Stats overview:
- Total users, active subscriptions, MRR (monthly recurring revenue)
- New users this week, churn rate this month
3. User table:
- Columns: name, email, plan, status, API calls used, joined date
- Search by name or email
- Pagination (20 per page)
- Click a row to see user details
4. Revenue chart:
- Monthly revenue for the last 12 months
- Use Stripe API to get actual invoice totals
Protect with middleware: if user.role !== "admin", redirect to /dashboard.
What Claude generated:
The admin middleware:
// middleware.ts addition
if (pathname.startsWith("/dashboard/admin")) {
const user = await prisma.user.findUnique({
where: { id: session.user.id },
});
if (user?.role !== "admin") {
return NextResponse.redirect(new URL("/dashboard", req.url));
}
}
The admin stats:
async function getAdminStats() {
const [totalUsers, activeSubscriptions, newUsersThisWeek] = await Promise.all([
prisma.user.count(),
prisma.subscription.count({ where: { status: "active" } }),
prisma.user.count({
where: { createdAt: { gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) } },
}),
]);
// Calculate MRR from Stripe
const subscriptions = await stripe.subscriptions.list({
status: "active",
limit: 100,
});
const mrr = subscriptions.data.reduce((total, sub) => {
const price = sub.items.data[0]?.price;
if (price?.recurring?.interval === "month") {
return total + (price.unit_amount || 0) / 100;
}
return total;
}, 0);
return { totalUsers, activeSubscriptions, mrr, newUsersThisWeek };
}
The user table:
function UserTable({ users, total, page }: { users: AdminUser[]; total: number; page: number }) {
return (
<div className="bg-white rounded-xl border overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50 border-b">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-500">User</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-500">Plan</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-500">Status</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-500">API Usage</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-500">Joined</th>
</tr>
</thead>
<tbody className="divide-y">
{users.map((user) => (
<tr key={user.id} className="hover:bg-gray-50 cursor-pointer">
<td className="px-4 py-3">
<div>
<p className="text-sm font-medium">{user.name}</p>
<p className="text-xs text-gray-500">{user.email}</p>
</div>
</td>
<td className="px-4 py-3">
<TierBadge tier={user.subscriptionTier} />
</td>
<td className="px-4 py-3">
<StatusBadge status={user.subscriptionStatus} />
</td>
<td className="px-4 py-3 text-sm">
{user.apiCallsUsed.toLocaleString()} / {user.apiCallsLimit.toLocaleString()}
</td>
<td className="px-4 py-3 text-sm text-gray-500">
{new Date(user.createdAt).toLocaleDateString()}
</td>
</tr>
))}
</tbody>
</table>
<Pagination total={total} page={page} perPage={20} />
</div>
);
}
My review: The admin panel looked professional. The MRR calculation from Stripe was a nice touch — it used real invoice data instead of estimating from the database.
Problem: The middleware check for admin role made a database query on every request to /dashboard/admin/*. In a real app, you would include the role in the JWT token to avoid the extra query. I added the role to the JWT callback:
async jwt({ token, user }) {
if (user) {
token.userId = user.id;
const dbUser = await prisma.user.findUnique({ where: { id: user.id } });
token.role = dbUser?.role || "user";
}
return token;
},
Time spent: 45 minutes
Prompt 6: Email Notifications (Minute 220)
Add email notifications using Resend:
1. Welcome email — sent after user registration
- Subject: "Welcome to [AppName]"
- HTML email with logo, greeting, and getting started steps
2. Subscription confirmation — sent after checkout.session.completed webhook
- Subject: "Your [Plan] subscription is active"
- Show plan details, next billing date, link to dashboard
3. Payment failed warning — sent on invoice.payment_failed webhook
- Subject: "Payment failed — action required"
- Explain what happened, link to update payment method
4. Use React Email for templates (renders to HTML)
5. Create a reusable sendEmail function in lib/email.ts
What Claude generated:
The email utility:
import { Resend } from "resend";
const resend = new Resend(process.env.RESEND_API_KEY);
export async function sendEmail({
to,
subject,
html,
}: {
to: string;
subject: string;
html: string;
}) {
try {
await resend.emails.send({
from: "AppName <noreply@yourdomain.com>",
to,
subject,
html,
});
} catch (error) {
console.error("Failed to send email:", error);
// Don't throw — email failure should not break the main flow
}
}
export function welcomeEmail(name: string): string {
return `
<div style="font-family: -apple-system, sans-serif; max-width: 600px; margin: 0 auto;">
<h1 style="color: #1a1a1a;">Welcome, ${name}!</h1>
<p style="color: #4a4a4a; line-height: 1.6;">
You have successfully created your account. Here is how to get started:
</p>
<ol style="color: #4a4a4a; line-height: 2;">
<li>Visit your <a href="${process.env.NEXT_PUBLIC_APP_URL}/dashboard">dashboard</a></li>
<li>Create your first project</li>
<li>Generate an API key</li>
<li>Make your first API call</li>
</ol>
<p style="color: #4a4a4a;">
You are on the <strong>Free plan</strong> with 100 API calls per month.
<a href="${process.env.NEXT_PUBLIC_APP_URL}/#pricing">Upgrade</a> for more.
</p>
</div>
`;
}
export function subscriptionEmail(name: string, tier: string, nextBilling: Date): string {
return `
<div style="font-family: -apple-system, sans-serif; max-width: 600px; margin: 0 auto;">
<h1 style="color: #1a1a1a;">Subscription Active</h1>
<p style="color: #4a4a4a; line-height: 1.6;">
Hi ${name}, your <strong>${tier}</strong> subscription is now active.
</p>
<div style="background: #f5f5f5; padding: 16px; border-radius: 8px; margin: 16px 0;">
<p style="margin: 0; color: #4a4a4a;">
<strong>Plan:</strong> ${tier}<br>
<strong>Next billing:</strong> ${nextBilling.toLocaleDateString()}<br>
<strong>Manage:</strong> <a href="${process.env.NEXT_PUBLIC_APP_URL}/dashboard/billing">Billing settings</a>
</p>
</div>
</div>
`;
}
export function paymentFailedEmail(name: string): string {
return `
<div style="font-family: -apple-system, sans-serif; max-width: 600px; margin: 0 auto;">
<h1 style="color: #dc2626;">Payment Failed</h1>
<p style="color: #4a4a4a; line-height: 1.6;">
Hi ${name}, your latest payment could not be processed.
</p>
<p style="color: #4a4a4a; line-height: 1.6;">
Please update your payment method to avoid service interruption.
</p>
<a href="${process.env.NEXT_PUBLIC_APP_URL}/dashboard/billing"
style="display: inline-block; background: #2563eb; color: white; padding: 12px 24px;
border-radius: 8px; text-decoration: none; margin-top: 16px;">
Update Payment Method
</a>
</div>
`;
}
My review: The email templates were simple and functional. Claude correctly wrapped the sendEmail call in a try-catch that does not throw — email failures should never break the subscription flow.
I wired the emails into the registration handler and the webhook handler. The welcome email sent on registration, the subscription email sent on checkout.session.completed, and the payment failed email sent on invoice.payment_failed.
No major issues. Resend’s API is straightforward.
Time spent: 22 minutes
Prompt 7: Polish and Error Handling (Minute 242)
Final polish:
1. Loading states: add skeleton loaders for dashboard cards and tables
2. Error boundaries: wrap dashboard pages in error boundaries with retry buttons
3. Optimistic UI: when clicking "Manage Subscription", show loading spinner immediately
4. Toast notifications: success/error toasts using sonner
5. Mobile responsive: collapsible sidebar on mobile
6. SEO: meta tags on landing page, Open Graph image
7. Security headers: CSP, CSRF protection, rate limiting on auth endpoints
8. Add a seed script that creates an admin user and sample data
What Claude generated:
Skeleton loaders:
function DashboardSkeleton() {
return (
<div className="space-y-6 animate-pulse">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{[1, 2, 3].map((i) => (
<div key={i} className="bg-white rounded-xl border p-6">
<div className="h-4 bg-gray-200 rounded w-24 mb-3" />
<div className="h-8 bg-gray-200 rounded w-16" />
</div>
))}
</div>
<div className="bg-white rounded-xl border p-6">
<div className="h-4 bg-gray-200 rounded w-32 mb-4" />
<div className="space-y-3">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="h-10 bg-gray-200 rounded" />
))}
</div>
</div>
</div>
);
}
The seed script:
// prisma/seed.ts
import { PrismaClient } from "@prisma/client";
import bcrypt from "bcryptjs";
const prisma = new PrismaClient();
async function main() {
const adminPassword = await bcrypt.hash("admin123", 12);
await prisma.user.upsert({
where: { email: "admin@example.com" },
update: {},
create: {
name: "Admin",
email: "admin@example.com",
passwordHash: adminPassword,
role: "admin",
subscriptionTier: "team",
subscriptionStatus: "active",
apiCallsLimit: 100000,
},
});
// Create 20 sample users
for (let i = 1; i <= 20; i++) {
const tiers = ["free", "pro", "team"];
const tier = tiers[i % 3] as string;
const limits = { free: 100, pro: 10000, team: 100000 };
await prisma.user.upsert({
where: { email: `user${i}@example.com` },
update: {},
create: {
name: `User ${i}`,
email: `user${i}@example.com`,
passwordHash: await bcrypt.hash("password", 12),
subscriptionTier: tier,
subscriptionStatus: "active",
apiCallsUsed: Math.floor(Math.random() * (limits[tier as keyof typeof limits] || 100)),
apiCallsLimit: limits[tier as keyof typeof limits] || 100,
},
});
}
console.log("Seeded database with admin + 20 sample users");
}
main();
My review: The skeleton loaders matched the layout of the actual components, so the loading transition was smooth. Toast notifications with Sonner were minimal to set up — just a <Toaster /> in the layout and toast.success("Subscription updated") where needed.
Problem: The security headers middleware conflicted with Stripe’s checkout redirect. The Content Security Policy blocked the redirect to checkout.stripe.com. I needed to add Stripe’s domains to the CSP:
"frame-src": ["https://checkout.stripe.com", "https://js.stripe.com"],
"connect-src": ["https://api.stripe.com"],
Time spent: 35 minutes
The remaining time (about 2 hours 25 minutes) was spent on:
- Testing the full Stripe flow end-to-end (subscribe, upgrade, downgrade, cancel)
- Fixing webhook edge cases (duplicate events, out-of-order events)
- Adding the
cancelAtPeriodEndfield to the Subscription model - Making the sidebar responsive with a hamburger menu on mobile
- Adding Prisma migration scripts
- Setting up Supabase database and running migrations
- Configuring environment variables for local development
- Testing Google OAuth flow
- Writing the seed script and verifying admin panel with sample data
Total time: 5 hours 22 minutes.
What Went Right
- Landing page was professional. The pricing table, feature grid, and hero section looked like a real SaaS product. Tailwind + shadcn/ui is a strong combination for dashboards.
- Stripe Checkout integration was smooth. Creating a checkout session and redirecting worked on the first try. The Stripe API is well-designed.
- Admin panel was functional. MRR from real Stripe data, user search with pagination, and tier badges. It would pass as an MVP admin panel.
- Email templates were clean. Simple inline CSS, no external dependencies, and they rendered correctly in Gmail and Apple Mail.
- The seed script was a great addition. It made testing the admin panel much easier with 20 sample users.
What Went Wrong
1. Stripe Webhook Signature Verification
Next.js App Router parses the request body automatically. Stripe’s webhook verification needs the raw body. Claude used req.text() which was correct, but it took debugging to realize the route needed export const runtime = "nodejs".
Lesson: Always test webhook verification locally with the Stripe CLI before deploying.
2. Subscription Metadata Mismatch
The userId metadata was set on subscription_data during checkout creation, but the checkout.session.completed event has the metadata on the session object, not the subscription. I had to retrieve the subscription separately and fall back to looking up by stripeCustomerId.
Lesson: Stripe’s event payloads are not always intuitive. Always log the full event object during development and inspect it in the Stripe dashboard.
3. Usage Tracking groupBy Limitation
Prisma’s groupBy does not support date extraction functions. I needed daily aggregation but could only group by the full timestamp. The fix was a raw SQL query.
Lesson: Prisma is great for 90% of queries but falls short for analytical queries. Keep a raw SQL escape hatch ready.
4. CSP Blocking Stripe Redirect
The Content Security Policy I added blocked Stripe’s checkout page. The browser silently failed the redirect — no error in the console, just nothing happened when clicking “Subscribe.”
Lesson: When adding CSP headers, test every external integration. Stripe, Google OAuth, analytics, and email verification all need their domains in the CSP.
5. Dashboard Showed “Free” After Payment
Between the checkout redirect and the webhook processing, there is a 1-3 second delay. Users see “Free” on their dashboard even though they just paid. I added a polling mechanism on the success page that checks subscription status every 2 seconds for up to 10 seconds.
Lesson: Webhook-based state updates have inherent latency. Add a polling or WebSocket mechanism for time-sensitive UI updates.
The Final Result
A complete SaaS starter template with:
- Landing page with pricing table
- User auth (Google + email/password)
- Stripe subscriptions (3 tiers)
- Stripe webhook handling (full lifecycle)
- User dashboard with subscription status and usage
- Billing portal via Stripe Customer Portal
- Usage tracking and rate limiting
- Admin panel with user management and revenue metrics
- Email notifications (welcome, subscription, payment failed)
- Responsive design with dark sidebar
To run it yourself:
git clone https://github.com/kemalcodes/vibe-coding-projects.git
cd vibe-coding-projects
git checkout saas-dashboard
# Set up environment variables
cp .env.example .env
# Add Stripe, Supabase, Google OAuth, Resend keys
# Install and set up
npm install
npx prisma migrate dev
npx prisma db seed
# Start
npm run dev
Open http://localhost:3000 for the landing page. Use test card 4242 4242 4242 4242 for Stripe.
Time and Cost
- Total time: 5 hours 22 minutes
- Claude prompts: 7 (plus 8 fixes)
- Estimated token usage: ~100,000 tokens
- Hosting cost: $0 (Vercel + Supabase + Resend free tiers)
- Stripe fees: 2.9% + $0.30 per transaction (only when live)
- Replaces: $200-$500 SaaS boilerplate kits
Lessons Learned
1. Stripe webhooks are the hardest part of any SaaS. Not the checkout. Not the billing portal. The webhooks. Event ordering, metadata access, signature verification, and error handling all require careful attention.
2. SaaS boilerplate is 80% solved by AI. The auth, dashboard, admin panel, and billing flow are all boilerplate that Claude handles well. The remaining 20% is your actual product logic.
3. Test the full subscription lifecycle. Do not just test “subscribe.” Test upgrade, downgrade, cancel at period end, immediate cancel, payment failure, and resubscribe. Each has different webhook events and database state transitions.
4. Free tiers make SaaS prototyping risk-free. Vercel, Supabase, Resend, and Stripe test mode — total cost $0 until you have paying users. There is no reason not to build and test a SaaS idea.
5. CSP headers break everything. If you add security headers, test every external redirect, every script, every iframe. Stripe, Google OAuth, and analytics all need explicit CSP exceptions.
Source Code
Full source code: kemalcodes/vibe-coding-projects (branch: saas-dashboard)
Related Articles
- Build a Real-Time Chat App with Claude — Previous article
- Build a Full-Stack Blog with Claude — Another Next.js project
- Build a CLI Todo App with Claude — First article in this series
- Vibe Coding with Claude — Complete Series — Series landing page
What’s Next?
Next up: Multi-Agent Project: Build a Codebase with 3 Claude Agents. We split a project across three specialized Claude agents — one for backend, one for frontend, one for testing — and see how well they coordinate. This is the future of AI-assisted development.