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

  1. Stripe webhooks are essential - Never trust client-side for subscription state
  2. Customer portal reduces support - Let users self-serve billing changes
  3. Usage tracking enables growth - Data-driven plan limits and pricing
  4. Test mode first - Always test thoroughly before live
  5. 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.

:::

Quiz

Module 1: Build a SaaS Starter Kit

Take Quiz