Next Starter Kit
Features

Payments & Subscriptions

Complete guide to Stripe integration for subscription billing

Payments & Subscriptions

The starter kit includes a complete Stripe integration for subscription billing with plans, checkout, customer portal, and webhook handling.

Features

  • Stripe Integration with Better Auth Stripe plugin
  • Subscription Plans (Free, Pro, Startup)
  • Monthly & Yearly Billing options
  • Checkout Sessions for upgrades
  • Customer Portal for self-service
  • Webhook Handling for real-time updates
  • Invoice Management and tracking
  • Usage Tracking (optional metered billing)
  • Plan Limits and feature gating

Setup

1. Create Stripe Account

Sign up at Stripe.com and get your API keys.

2. Configure Environment Variables

# Stripe API Keys
STRIPE_SECRET_KEY="sk_test_xxxxxxxxxxxx"
STRIPE_PUBLISHABLE_KEY="pk_test_xxxxxxxxxxxx"

# Webhook Secret (from Stripe Dashboard)
STRIPE_WEBHOOK_SECRET="whsec_xxxxxxxxxxxx"

3. Create Products in Stripe

  1. Go to Products
  2. Create products:
    • Pro with monthly and yearly prices
    • Startup with monthly and yearly prices
  3. Copy Price IDs

4. Configure Price IDs

STRIPE_PRICE_PRO_MONTHLY="price_xxxxxxxxxxxx"
STRIPE_PRICE_PRO_YEARLY="price_xxxxxxxxxxxx"
STRIPE_PRICE_STARTUP_MONTHLY="price_xxxxxxxxxxxx"
STRIPE_PRICE_STARTUP_YEARLY="price_xxxxxxxxxxxx"

5. Set Up Webhooks

  1. Go to Webhooks
  2. Add endpoint: https://yourdomain.com/api/stripe/webhook
  3. Select events:
    • customer.subscription.created
    • customer.subscription.updated
    • customer.subscription.deleted
    • invoice.paid
    • invoice.payment_failed
  4. Copy webhook signing secret to STRIPE_WEBHOOK_SECRET

6. Seed Subscription Plans

pnpm plans:setup

This creates the three default plans in your database.

Subscription Plans

Plan Structure

Plans are stored in the subscriptionPlans table:

{
  id: string
  name: "Free" | "Pro" | "Startup"
  slug: "free" | "pro" | "startup"
  description: string

  // Stripe Price IDs
  stripePriceIdMonthly: string
  stripePriceIdYearly: string

  // Pricing (in cents)
  priceMonthly: number  // 0 for Free, 1999 for Pro ($19.99)
  priceYearly: number   // 0 for Free, 19900 for Pro ($199)

  // Features list
  features: string[]  // ["Feature 1", "Feature 2"]

  // Limits
  limits: {
    storage?: number
    apiCalls?: number
    teamMembers?: number
  }

  isActive: boolean
  sortOrder: number
}

Default Plans

Free Plan:

{
  name: "Free",
  priceMonthly: 0,
  priceYearly: 0,
  features: [
    "1 user",
    "Basic features",
    "Community support"
  ],
  limits: {
    storage: 1024, // 1GB
    apiCalls: 1000
  }
}

Pro Plan:

{
  name: "Pro",
  priceMonthly: 1999, // $19.99
  priceYearly: 19900, // $199 (save $40)
  features: [
    "5 users",
    "Advanced features",
    "Priority support",
    "Advanced analytics"
  ],
  limits: {
    storage: 10240, // 10GB
    apiCalls: 10000
  }
}

Startup Plan:

{
  name: "Startup",
  priceMonthly: 4999, // $49.99
  priceYearly: 49900, // $499 (save $100)
  features: [
    "Unlimited users",
    "All features",
    "24/7 support",
    "Custom integrations",
    "SLA guarantee"
  ],
  limits: {
    storage: 102400, // 100GB
    apiCalls: 100000
  }
}

Managing Plans

Navigate to /dashboard/admin/subscription-plans to:

  • View all plans
  • Edit pricing and features
  • Toggle plan visibility
  • Update limits
  • Sync with Stripe

User Subscriptions

Subscription States

subscriptions {
  userId: string
  planId: string

  status: "active" | "canceled" | "past_due" | "trialing" | "incomplete"
  billingCycle: "monthly" | "yearly"

  stripeCustomerId: string
  stripeSubscriptionId: string
  stripePriceId: string
  stripeCurrentPeriodEnd: Date

  canceledAt: Date | null
  cancelAtPeriodEnd: boolean
  trialEndsAt: Date | null
}

Status Meanings

  • active: Subscription is active and paid
  • trialing: In trial period (if configured)
  • past_due: Payment failed, retrying
  • canceled: Subscription canceled
  • incomplete: Checkout not completed

Checkout Flow

1. User Selects Plan

User visits /pricing and clicks "Upgrade to Pro".

2. Create Checkout Session

// app/api/subscription/checkout/route.ts
import { stripe } from "@/lib/stripe";
import { auth } from "@/lib/auth";

export async function POST(request: Request) {
  const session = await auth.api.getSession({
    headers: request.headers,
  });

  if (!session?.user) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const { priceId, billingCycle } = await request.json();

  // Create Stripe checkout session
  const checkoutSession = await stripe.checkout.sessions.create({
    customer_email: session.user.email,
    mode: "subscription",
    payment_method_types: ["card"],
    line_items: [
      {
        price: priceId,
        quantity: 1,
      },
    ],
    success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard/user/billing?success=true`,
    cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing?canceled=true`,
    metadata: {
      userId: session.user.id,
      billingCycle,
    },
  });

  return NextResponse.json({ url: checkoutSession.url });
}

3. Redirect to Stripe Checkout

// components/pricing-card.tsx
"use client";

export function PricingCard({ plan, billingCycle }) {
  const handleCheckout = async () => {
    const response = await fetch("/api/subscription/checkout", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        priceId:
          billingCycle === "yearly"
            ? plan.stripePriceIdYearly
            : plan.stripePriceIdMonthly,
        billingCycle,
      }),
    });

    const { url } = await response.json();
    window.location.href = url; // Redirect to Stripe
  };

  return <Button onClick={handleCheckout}>Upgrade to {plan.name}</Button>;
}

4. Stripe Processes Payment

User completes payment on Stripe-hosted checkout page.

5. Webhook Updates Database

Stripe sends customer.subscription.created event to your webhook.

Webhooks

Webhook Handler

// app/api/stripe/webhook/route.ts
import { stripe } from "@/lib/stripe";
import { db } from "@/db";
import { subscriptions } from "@/db/schema";

export async function POST(request: Request) {
  const body = await request.text();
  const signature = request.headers.get("stripe-signature");

  let event;

  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature!,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch (err) {
    return NextResponse.json(
      { error: "Webhook signature verification failed" },
      { status: 400 }
    );
  }

  // Handle events
  switch (event.type) {
    case "customer.subscription.created":
    case "customer.subscription.updated":
      await handleSubscriptionUpdate(event.data.object);
      break;

    case "customer.subscription.deleted":
      await handleSubscriptionDeleted(event.data.object);
      break;

    case "invoice.paid":
      await handleInvoicePaid(event.data.object);
      break;

    case "invoice.payment_failed":
      await handleInvoicePaymentFailed(event.data.object);
      break;
  }

  return NextResponse.json({ received: true });
}

async function handleSubscriptionUpdate(subscription: any) {
  await db
    .insert(subscriptions)
    .values({
      userId: subscription.metadata.userId,
      stripeCustomerId: subscription.customer,
      stripeSubscriptionId: subscription.id,
      stripePriceId: subscription.items.data[0].price.id,
      status: subscription.status,
      stripeCurrentPeriodEnd: new Date(subscription.current_period_end * 1000),
    })
    .onConflictDoUpdate({
      target: subscriptions.stripeSubscriptionId,
      set: {
        status: subscription.status,
        stripeCurrentPeriodEnd: new Date(
          subscription.current_period_end * 1000
        ),
        updatedAt: new Date(),
      },
    });
}

Events Handled

  • customer.subscription.created: New subscription
  • customer.subscription.updated: Plan change, renewal
  • customer.subscription.deleted: Cancellation
  • invoice.paid: Payment successful
  • invoice.payment_failed: Payment failed

Customer Portal

Stripe Customer Portal

Users can manage their subscription at /dashboard/user/billing:

// app/api/subscription/portal/route.ts
import { stripe } from "@/lib/stripe";

export async function POST(request: Request) {
  const session = await auth.api.getSession({
    headers: request.headers,
  });

  if (!session?.user) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  // Get user's subscription
  const subscription = await db.query.subscriptions.findFirst({
    where: eq(subscriptions.userId, session.user.id),
  });

  if (!subscription?.stripeCustomerId) {
    return NextResponse.json(
      { error: "No subscription found" },
      { status: 404 }
    );
  }

  // Create portal session
  const portalSession = await stripe.billingPortal.sessions.create({
    customer: subscription.stripeCustomerId,
    return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard/user/billing`,
  });

  return NextResponse.json({ url: portalSession.url });
}

Portal Features

Users can:

  • View current plan and billing cycle
  • Update payment method
  • View invoice history
  • Download invoices
  • Cancel subscription
  • Upgrade/downgrade plan
  • Update billing information

Feature Gating

Checking Subscription Status

// lib/subscription.ts
import { db } from "@/db";
import { subscriptions } from "@/db/schema";
import { eq } from "drizzle-orm";

export async function getUserSubscription(userId: string) {
  const subscription = await db.query.subscriptions.findFirst({
    where: eq(subscriptions.userId, userId),
    with: {
      plan: true,
    },
  });

  return subscription;
}

export async function hasActiveSubscription(userId: string) {
  const subscription = await getUserSubscription(userId);
  return subscription?.status === "active";
}

export async function canAccessFeature(userId: string, feature: string) {
  const subscription = await getUserSubscription(userId);

  if (!subscription || !subscription.plan) {
    return false; // Free plan
  }

  return subscription.plan.features.includes(feature);
}

Usage in Components

// Server component
export default async function ProtectedFeature() {
  const session = await auth.api.getSession({ headers: await headers() });

  if (!session?.user) {
    redirect("/sign-in");
  }

  const hasAccess = await canAccessFeature(
    session.user.id,
    "Advanced Analytics"
  );

  if (!hasAccess) {
    return (
      <div>
        <h2>Upgrade Required</h2>
        <p>This feature requires a Pro or Startup plan.</p>
        <Link href="/pricing">View Plans</Link>
      </div>
    );
  }

  return <AdvancedAnalyticsDashboard />;
}

Client-Side Gating

"use client";
import { useSubscription } from "@/hooks/use-subscription";

export function PremiumFeature() {
  const { subscription, isPending } = useSubscription();

  if (isPending) return <Skeleton />;

  const isPro =
    subscription?.plan?.slug === "pro" ||
    subscription?.plan?.slug === "startup";

  if (!isPro) {
    return <UpgradePrompt />;
  }

  return <PremiumContent />;
}

Invoice Management

Viewing Invoices

Users can view their invoice history:

// app/dashboard/user/billing/page.tsx
import { db } from "@/db";
import { invoices } from "@/db/schema";
import { eq, desc } from "drizzle-orm";

export default async function BillingPage() {
  const session = await auth.api.getSession({ headers: await headers() });

  const userInvoices = await db.query.invoices.findMany({
    where: eq(invoices.userId, session.user.id),
    orderBy: [desc(invoices.createdAt)],
  });

  return (
    <div>
      <h1>Billing History</h1>
      <InvoiceTable invoices={userInvoices} />
    </div>
  );
}

Invoice Data

{
  stripeInvoiceId: string;
  amountPaid: number; // in cents
  amountDue: number;
  currency: "usd";
  status: "paid" | "open" | "void" | "uncollectible";
  hostedInvoiceUrl: string; // Stripe-hosted page
  invoicePdf: string; // PDF download link
  periodStart: Date;
  periodEnd: Date;
  paidAt: Date;
}

Testing

Test Mode

Use Stripe test mode during development:

STRIPE_SECRET_KEY="sk_test_xxxxxxxxxxxx"
STRIPE_PUBLISHABLE_KEY="pk_test_xxxxxxxxxxxx"

Test Cards

Use Stripe test cards:

  • Success: 4242 4242 4242 4242
  • Decline: 4000 0000 0000 0002
  • Requires Authentication: 4000 0025 0000 3155

Expiry: Any future date
CVC: Any 3 digits
ZIP: Any 5 digits

Webhook Testing

Test webhooks locally with Stripe CLI:

# Install Stripe CLI
brew install stripe/stripe-cli/stripe

# Login
stripe login

# Forward webhooks to local dev
stripe listen --forward-to localhost:3000/api/stripe/webhook

# Trigger test events
stripe trigger customer.subscription.created
stripe trigger invoice.paid

Production Deployment

1. Switch to Live Keys

STRIPE_SECRET_KEY="sk_live_xxxxxxxxxxxx"
STRIPE_PUBLISHABLE_KEY="pk_live_xxxxxxxxxxxx"

2. Update Webhook Endpoint

Point webhook to production URL:

https://yourdomain.com/api/stripe/webhook

3. Verify Integration

  • Test checkout flow
  • Verify webhooks are received
  • Check subscription creation
  • Test customer portal

Best Practices

  1. Always verify webhooks with signing secret
  2. Handle idempotency for webhook events
  3. Use metadata to link Stripe objects to your users
  4. Keep prices in cents to avoid floating-point issues
  5. Test failure scenarios (failed payments, etc.)
  6. Monitor Stripe Dashboard for issues
  7. Set up email notifications for failed payments

Troubleshooting

Webhook not received

  • Verify webhook URL is accessible
  • Check Stripe Dashboard > Webhooks for errors
  • Ensure HTTPS in production
  • Check webhook signing secret

Subscription not updating

  • Check webhook handler logs
  • Verify database updates in webhook code
  • Ensure userId is in metadata
  • Check Stripe event logs

Payment fails

  • Verify test card numbers
  • Check customer has valid payment method
  • Review failed payment in Stripe Dashboard
  • Ensure SCA (3D Secure) is handled

Next Steps

On this page