Build a SaaS Starter Kit
Billing Integration & Dashboard
5 min read
What You'll Build
Complete billing system with:
- Stripe subscription checkout
- Customer portal for plan management
- Webhook handling for subscription events
- Usage-based billing support
- Professional dashboard with analytics
- Plan upgrade/downgrade flows
Time to complete: ~60 minutes with AI assistance
Step 1: Stripe Setup
First, set up Stripe integration:
Set up Stripe integration for SaaS billing:
1. src/lib/stripe/client.ts
- Initialize Stripe SDK
- Type-safe configuration
2. src/lib/stripe/prices.ts
- Define pricing plans (Free, Starter $19/mo, Pro $49/mo, Enterprise)
- Map to Stripe Price IDs
- Feature lists for each plan
3. Environment variables needed:
- STRIPE_SECRET_KEY
- STRIPE_PUBLISHABLE_KEY
- STRIPE_WEBHOOK_SECRET
- STRIPE_STARTER_PRICE_ID
- STRIPE_PRO_PRICE_ID
AI generates:
// src/lib/stripe/client.ts
import Stripe from 'stripe';
import { env } from '@/lib/env';
export const stripe = new Stripe(env.STRIPE_SECRET_KEY, {
apiVersion: '2024-12-18.acacia',
typescript: true,
});
// src/lib/stripe/prices.ts
export const PLANS = {
free: {
name: 'Free',
price: 0,
priceId: null,
features: [
'1 project',
'100 AI generations/month',
'Community support',
],
limits: {
projects: 1,
aiGenerations: 100,
teamMembers: 1,
},
},
starter: {
name: 'Starter',
price: 19,
priceId: process.env.STRIPE_STARTER_PRICE_ID,
features: [
'5 projects',
'1,000 AI generations/month',
'Email support',
'API access',
],
limits: {
projects: 5,
aiGenerations: 1000,
teamMembers: 3,
},
},
pro: {
name: 'Pro',
price: 49,
priceId: process.env.STRIPE_PRO_PRICE_ID,
features: [
'Unlimited projects',
'10,000 AI generations/month',
'Priority support',
'API access',
'Custom integrations',
'Team collaboration',
],
limits: {
projects: -1, // unlimited
aiGenerations: 10000,
teamMembers: 10,
},
},
enterprise: {
name: 'Enterprise',
price: null, // custom pricing
priceId: null,
features: [
'Everything in Pro',
'Unlimited AI generations',
'Dedicated support',
'SLA guarantee',
'Custom contracts',
'SSO/SAML',
],
limits: {
projects: -1,
aiGenerations: -1,
teamMembers: -1,
},
},
} as const;
export type PlanKey = keyof typeof PLANS;
Step 2: Checkout Flow
Create the checkout experience:
Create Stripe checkout flow:
1. src/app/api/stripe/checkout/route.ts
- Create checkout session for plan upgrade
- Include success/cancel URLs
- Set customer metadata (orgId, userId)
2. src/lib/stripe/actions.ts
- createCheckoutSession(orgId: string, priceId: string)
- Server action with proper validation
3. src/components/pricing/pricing-card.tsx
- Display plan features
- "Current Plan" badge for active plan
- Upgrade/Downgrade button
- Contact sales for Enterprise
Example checkout route:
// src/app/api/stripe/checkout/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { stripe } from '@/lib/stripe/client';
import { db } from '@/lib/db';
import { organizations, subscriptions } from '@/lib/db/schema';
import { eq } from 'drizzle-orm';
import { env } from '@/lib/env';
export async function POST(req: NextRequest) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { orgId, priceId } = await req.json();
// Get organization and verify ownership
const org = await db.query.organizations.findFirst({
where: eq(organizations.id, orgId),
with: { subscription: true },
});
if (!org || org.ownerId !== session.user.id) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// Get or create Stripe customer
let customerId = org.subscription?.stripeCustomerId;
if (!customerId) {
const customer = await stripe.customers.create({
email: session.user.email!,
metadata: {
orgId: org.id,
userId: session.user.id,
},
});
customerId = customer.id;
}
// Create checkout session
const checkoutSession = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'subscription',
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${env.NEXT_PUBLIC_APP_URL}/dashboard/settings/billing?success=true`,
cancel_url: `${env.NEXT_PUBLIC_APP_URL}/dashboard/settings/billing?canceled=true`,
metadata: {
orgId: org.id,
userId: session.user.id,
},
subscription_data: {
metadata: {
orgId: org.id,
},
},
});
return NextResponse.json({ url: checkoutSession.url });
} catch (error) {
console.error('Checkout error:', error);
return NextResponse.json(
{ error: 'Failed to create checkout session' },
{ status: 500 }
);
}
}
Step 3: Webhook Handling
Handle Stripe events:
Create Stripe webhook handler:
1. src/app/api/stripe/webhook/route.ts
- Verify webhook signature
- Handle events:
- checkout.session.completed
- customer.subscription.updated
- customer.subscription.deleted
- invoice.payment_failed
- Update database subscription records
2. src/lib/stripe/webhook-handlers.ts
- Separate handler functions for each event type
- Proper error handling and logging
AI generates:
// src/app/api/stripe/webhook/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe/client';
import { db } from '@/lib/db';
import { subscriptions, organizations } from '@/lib/db/schema';
import { eq } from 'drizzle-orm';
import { env } from '@/lib/env';
import Stripe from 'stripe';
export async function POST(req: NextRequest) {
const body = await req.text();
const signature = req.headers.get('stripe-signature')!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
console.error('Webhook signature verification failed');
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
}
try {
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
await handleCheckoutCompleted(session);
break;
}
case 'customer.subscription.updated': {
const subscription = event.data.object as Stripe.Subscription;
await handleSubscriptionUpdated(subscription);
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription;
await handleSubscriptionDeleted(subscription);
break;
}
case 'invoice.payment_failed': {
const invoice = event.data.object as Stripe.Invoice;
await handlePaymentFailed(invoice);
break;
}
}
return NextResponse.json({ received: true });
} catch (error) {
console.error('Webhook handler error:', error);
return NextResponse.json(
{ error: 'Webhook handler failed' },
{ status: 500 }
);
}
}
async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
const orgId = session.metadata?.orgId;
if (!orgId) return;
const subscription = await stripe.subscriptions.retrieve(
session.subscription as string
);
await db.insert(subscriptions).values({
organizationId: orgId,
stripeCustomerId: session.customer as string,
stripeSubscriptionId: subscription.id,
status: subscription.status,
plan: getPlanFromPriceId(subscription.items.data[0].price.id),
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
}).onConflictDoUpdate({
target: subscriptions.organizationId,
set: {
stripeSubscriptionId: subscription.id,
status: subscription.status,
plan: getPlanFromPriceId(subscription.items.data[0].price.id),
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
},
});
// Update organization plan
await db.update(organizations)
.set({ plan: getPlanFromPriceId(subscription.items.data[0].price.id) })
.where(eq(organizations.id, orgId));
}
async function handleSubscriptionUpdated(subscription: Stripe.Subscription) {
const orgId = subscription.metadata?.orgId;
if (!orgId) return;
await db.update(subscriptions)
.set({
status: subscription.status,
plan: getPlanFromPriceId(subscription.items.data[0].price.id),
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
})
.where(eq(subscriptions.stripeSubscriptionId, subscription.id));
}
async function handleSubscriptionDeleted(subscription: Stripe.Subscription) {
await db.update(subscriptions)
.set({ status: 'canceled' })
.where(eq(subscriptions.stripeSubscriptionId, subscription.id));
}
async function handlePaymentFailed(invoice: Stripe.Invoice) {
// Send notification email, update UI, etc.
console.log('Payment failed for invoice:', invoice.id);
}
function getPlanFromPriceId(priceId: string): 'free' | 'starter' | 'pro' {
if (priceId === process.env.STRIPE_STARTER_PRICE_ID) return 'starter';
if (priceId === process.env.STRIPE_PRO_PRICE_ID) return 'pro';
return 'free';
}
Step 4: Customer Portal
Enable self-service billing management:
Create Stripe Customer Portal integration:
1. src/app/api/stripe/portal/route.ts
- Create portal session
- Redirect to Stripe-hosted portal
- User can update payment, cancel, view invoices
2. src/components/billing/manage-subscription-button.tsx
- Button that opens customer portal
- Loading state
// src/app/api/stripe/portal/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { stripe } from '@/lib/stripe/client';
import { db } from '@/lib/db';
import { subscriptions } from '@/lib/db/schema';
import { eq } from 'drizzle-orm';
import { env } from '@/lib/env';
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { orgId } = await req.json();
const subscription = await db.query.subscriptions.findFirst({
where: eq(subscriptions.organizationId, orgId),
});
if (!subscription?.stripeCustomerId) {
return NextResponse.json({ error: 'No subscription found' }, { status: 404 });
}
const portalSession = await stripe.billingPortal.sessions.create({
customer: subscription.stripeCustomerId,
return_url: `${env.NEXT_PUBLIC_APP_URL}/dashboard/settings/billing`,
});
return NextResponse.json({ url: portalSession.url });
}
Step 5: Build the Dashboard
Create a professional dashboard:
Create dashboard pages and components:
1. src/app/(dashboard)/dashboard/page.tsx
- Welcome message with user name
- Quick stats cards (projects, usage, team members)
- Recent activity feed
- Quick actions
2. src/components/dashboard/stats-cards.tsx
- Animated stat cards with icons
- Usage progress bars
- Click to drill down
3. src/components/dashboard/usage-chart.tsx
- Line chart of AI generations over time
- Use Recharts or Chart.js
- Last 30 days view
4. src/components/dashboard/activity-feed.tsx
- Recent actions (created project, invited member, etc.)
- Relative timestamps
- User avatars
Use Tailwind + shadcn/ui for consistent styling.
Example stats cards:
// src/components/dashboard/stats-cards.tsx
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { FolderKanban, Sparkles, Users } from 'lucide-react';
import { useOrganization } from '@/hooks/use-organization';
import { PLANS } from '@/lib/stripe/prices';
interface StatsCardsProps {
projectCount: number;
aiGenerationsUsed: number;
teamMemberCount: number;
}
export function StatsCards({
projectCount,
aiGenerationsUsed,
teamMemberCount,
}: StatsCardsProps) {
const { organization } = useOrganization();
const plan = PLANS[organization?.plan ?? 'free'];
const aiLimit = plan.limits.aiGenerations;
const aiPercentage = aiLimit > 0 ? (aiGenerationsUsed / aiLimit) * 100 : 0;
return (
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Projects
</CardTitle>
<FolderKanban className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{projectCount}</div>
<p className="text-xs text-muted-foreground">
{plan.limits.projects === -1
? 'Unlimited'
: `of ${plan.limits.projects} available`}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
AI Generations
</CardTitle>
<Sparkles className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{aiGenerationsUsed.toLocaleString()}
</div>
{aiLimit > 0 && (
<>
<Progress value={aiPercentage} className="mt-2" />
<p className="mt-1 text-xs text-muted-foreground">
{aiLimit.toLocaleString()} monthly limit
</p>
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Team Members
</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{teamMemberCount}</div>
<p className="text-xs text-muted-foreground">
{plan.limits.teamMembers === -1
? 'Unlimited seats'
: `of ${plan.limits.teamMembers} seats`}
</p>
</CardContent>
</Card>
</div>
);
}
Step 6: Billing Settings Page
Create the billing management UI:
Create billing settings page:
1. src/app/(dashboard)/settings/billing/page.tsx
- Current plan display
- Usage overview
- Plan comparison table
- Upgrade/downgrade buttons
- Payment method display
- Invoice history link
2. src/components/billing/current-plan.tsx
- Plan name and price
- Next billing date
- "Manage Subscription" button (portal)
3. src/components/billing/plan-comparison.tsx
- Feature comparison table
- Highlight current plan
- CTA buttons for each plan
Step 7: Usage Tracking
Implement usage metering:
Create usage tracking system:
1. src/lib/usage/track.ts
- trackUsage(orgId: string, metric: string, amount: number)
- Increment usage counters
2. src/lib/usage/check.ts
- checkLimit(orgId: string, metric: string)
- Return { allowed: boolean, current: number, limit: number }
3. src/lib/db/schema.ts (add)
- usage_records table
- Daily/monthly aggregation
4. Use in API routes:
- Check limits before AI operations
- Return 429 if over limit
- Include usage info in response headers
// src/lib/usage/track.ts
import { db } from '@/lib/db';
import { usageRecords } from '@/lib/db/schema';
import { eq, and, sql } from 'drizzle-orm';
export async function trackUsage(
orgId: string,
metric: 'ai_generations' | 'api_calls' | 'storage_mb',
amount: number = 1
) {
const today = new Date().toISOString().split('T')[0];
await db.insert(usageRecords)
.values({
organizationId: orgId,
metric,
amount,
date: today,
})
.onConflictDoUpdate({
target: [usageRecords.organizationId, usageRecords.metric, usageRecords.date],
set: {
amount: sql`${usageRecords.amount} + ${amount}`,
},
});
}
// src/lib/usage/check.ts
import { db } from '@/lib/db';
import { usageRecords, organizations } from '@/lib/db/schema';
import { eq, and, sql, gte } from 'drizzle-orm';
import { PLANS } from '@/lib/stripe/prices';
export async function checkLimit(
orgId: string,
metric: 'ai_generations' | 'api_calls'
) {
const org = await db.query.organizations.findFirst({
where: eq(organizations.id, orgId),
});
if (!org) throw new Error('Organization not found');
const plan = PLANS[org.plan];
const limit = metric === 'ai_generations'
? plan.limits.aiGenerations
: Infinity;
if (limit === -1) {
return { allowed: true, current: 0, limit: -1 };
}
// Get current month's usage
const startOfMonth = new Date();
startOfMonth.setDate(1);
startOfMonth.setHours(0, 0, 0, 0);
const [usage] = await db
.select({ total: sql<number>`sum(${usageRecords.amount})` })
.from(usageRecords)
.where(
and(
eq(usageRecords.organizationId, orgId),
eq(usageRecords.metric, metric),
gte(usageRecords.date, startOfMonth.toISOString().split('T')[0])
)
);
const current = usage?.total ?? 0;
return {
allowed: current < limit,
current,
limit,
remaining: Math.max(0, limit - current),
};
}
Step 8: Test Billing Flow
Test checklist:
Test the complete billing flow:
1. Stripe Test Mode
- Use test API keys
- Test card: 4242 4242 4242 4242
2. Checkout Flow
- [ ] Free user can upgrade to Starter
- [ ] Starter user can upgrade to Pro
- [ ] Checkout redirects correctly
- [ ] Success page shows confirmation
3. Webhook Handling
- [ ] Use Stripe CLI for local testing: stripe listen --forward-to localhost:3000/api/stripe/webhook
- [ ] Subscription created on checkout.session.completed
- [ ] Plan updates on subscription.updated
- [ ] Handles cancellation
4. Customer Portal
- [ ] Portal opens correctly
- [ ] Can update payment method
- [ ] Can cancel subscription
- [ ] Returns to app correctly
5. Usage Limits
- [ ] Free plan limited correctly
- [ ] Usage increments on API calls
- [ ] 429 returned when over limit
Deployment Checklist
Before going live:
- Switch to Stripe live keys
- Configure webhook endpoint in Stripe dashboard
- Set up Stripe customer portal branding
- Create production price IDs
- Test with real card (refund after)
- Enable fraud protection (Radar)
- Set up usage alerts
Key Takeaways
- Stripe webhooks are essential - Never trust client-side for subscription state
- Customer portal reduces support - Let users self-serve billing changes
- Usage tracking enables growth - Data-driven plan limits and pricing
- Test mode first - Always test thoroughly before live
- AI accelerates integration - Complex billing setup in hours, not days
What's Next
Congratulations! You've built a complete SaaS starter kit with authentication, organizations, billing, and dashboard. In Module 2, you'll learn to build mobile apps with AI assistance.
CTA: Continue to Module 2: Build Mobile Apps with AI to create cross-platform mobile applications using React Native and Expo.
:::