Demo

Monday, July 15th 2024 · 3 min read

How to Implement Stripe Billing in Next.js

A comprehensive guide to integrating Stripe payments in your Next.js application, covering subscriptions, one-time payments, webhooks, customer portal, and production best practices.

Implementing billing in a SaaS application is one of the most critical features to get right. In this comprehensive guide, we'll walk through how to integrate Stripe billing in a Next.js application using Server Actions, covering everything from basic setup to production-ready implementations.

Why Stripe for SaaS Billing?

Stripe has become the de facto standard for SaaS billing because of:

  • Developer Experience: Exceptional APIs, SDKs, and documentation
  • Global Support: 135+ currencies and dozens of payment methods
  • Flexibility: Subscriptions, one-time payments, metered billing, and usage-based pricing
  • Security: PCI DSS Level 1 compliance handled for you
  • Ecosystem: Customer Portal, Invoicing, Tax, Revenue Recognition

Project Setup

First, install the required packages:

Terminal
pnpm install stripe @stripe/stripe-js

Create your environment variables:

.env.local
# Stripe API KeysSTRIPE_SECRET_KEY=sk_test_...NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...STRIPE_WEBHOOK_SECRET=whsec_...# Your app URLNEXT_PUBLIC_APP_URL=http://localhost:3000

Server-Side Stripe Client

Create a singleton Stripe instance for server-side operations:

lib/stripe.ts
import Stripe from 'stripe';if (!process.env.STRIPE_SECRET_KEY) {  throw new Error('STRIPE_SECRET_KEY is not set');}export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {  apiVersion: '2024-06-20',  typescript: true});

Creating Products and Prices

Before accepting payments, you need products and prices in Stripe. You can create them via the Dashboard or programmatically:

scripts/create-products.ts
import Stripe from 'stripe';const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);async function createProducts() {  // Create a product  const product = await stripe.products.create({    name: 'Pro Plan',    description: 'Full access to all premium features',    active: true,    metadata: {      tier: 'pro'    }  });  // Create a monthly recurring price  const monthlyPrice = await stripe.prices.create({    product: product.id,    unit_amount: 2900, // $29.00    currency: 'usd',    recurring: {      interval: 'month',      interval_count: 1,      usage_type: 'licensed'    },    metadata: { billing_cycle: 'monthly' }  });  // Create a yearly recurring price (with discount)  const yearlyPrice = await stripe.prices.create({    product: product.id,    unit_amount: 29000, // $290.00 (2 months free)    currency: 'usd',    recurring: {      interval: 'year',      interval_count: 1,      usage_type: 'licensed'    },    metadata: { billing_cycle: 'yearly' }  });  console.log('Product:', product.id);  console.log('Monthly Price:', monthlyPrice.id);  console.log('Yearly Price:', yearlyPrice.id);}createProducts();

Creating Checkout Sessions

The easiest way to accept payments is via Stripe Checkout. Create a Server Action to handle this:

app/actions/checkout.ts
'use server';import { redirect } from 'next/navigation';import { auth } from '@/lib/auth';import { stripe } from '@/lib/stripe';export async function createCheckoutSession(priceId: string) {  const session = await auth();  if (!session?.user?.id) {    redirect('/login');  }  // Check if user already has a Stripe customer ID  const user = await db.user.findUnique({    where: { id: session.user.id },    select: { stripeCustomerId: true, email: true }  });  let customerId = user?.stripeCustomerId;  // Create Stripe customer if doesn't exist  if (!customerId) {    const customer = await stripe.customers.create({      email: user?.email || session.user.email!,      metadata: {        userId: session.user.id      }    });    customerId = customer.id;    // Save customer ID to database    await db.user.update({      where: { id: session.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}/billing?success=true&session_id={CHECKOUT_SESSION_ID}`,    cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/billing?canceled=true`,    subscription_data: {      trial_period_days: 14,      metadata: {        userId: session.user.id      }    },    allow_promotion_codes: true,    billing_address_collection: 'required',    customer_update: {      address: 'auto',      name: 'auto'    }  });  redirect(checkoutSession.url!);}

Use it in a Client Component:

components/pricing-button.tsx
'use client';import { useState } from 'react';import { createCheckoutSession } from '@/app/actions/checkout';import { Button } from '@/components/ui/button';export function PricingButton({ priceId }: { priceId: string }) {  const [loading, setLoading] = useState(false);  const handleClick = async () => {    setLoading(true);    await createCheckoutSession(priceId);  };  return (    <Button      onClick={handleClick}      disabled={loading}    >      {loading ? 'Redirecting...' : 'Subscribe'}    </Button>  );}

Handling Webhooks

Webhooks are essential for keeping your database in sync with Stripe. This is the most critical part of your billing implementation:

app/api/webhooks/stripe/route.ts
import { headers } from 'next/headers';import { NextResponse } from 'next/server';import type Stripe from 'stripe';import { stripe } from '@/lib/stripe';const relevantEvents = new Set([  'checkout.session.completed',  'customer.subscription.created',  'customer.subscription.updated',  'customer.subscription.deleted',  'invoice.payment_succeeded',  'invoice.payment_failed']);export async function POST(req: Request) {  const body = await req.text();  const signature = headers().get('stripe-signature');  if (!signature) {    return NextResponse.json(      { error: 'Missing stripe-signature header' },      { status: 400 }    );  }  let event: Stripe.Event;  try {    event = stripe.webhooks.constructEvent(      body,      signature,      process.env.STRIPE_WEBHOOK_SECRET!    );  } catch (err) {    const message = err instanceof Error ? err.message : 'Unknown error';    console.error(`Webhook signature verification failed: ${message}`);    return NextResponse.json(      { error: `Webhook Error: ${message}` },      { 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 handleSubscriptionChange(subscription);        break;      }      case 'customer.subscription.deleted': {        const subscription = event.data.object as Stripe.Subscription;        await handleSubscriptionDeleted(subscription);        break;      }      case 'invoice.payment_succeeded': {        const invoice = event.data.object as Stripe.Invoice;        await handleInvoicePaid(invoice);        break;      }      case 'invoice.payment_failed': {        const invoice = event.data.object as Stripe.Invoice;        await handleInvoiceFailed(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 userId = session.metadata?.userId;  const subscriptionId = session.subscription as string;  if (!userId || !subscriptionId) return;  // Fetch the full subscription object  const subscription = await stripe.subscriptions.retrieve(subscriptionId, {    expand: ['items.data.price.product']  });  // Update user's subscription in database  await db.subscription.upsert({    where: { userId },    create: {      userId,      stripeSubscriptionId: subscription.id,      stripePriceId: subscription.items.data[0].price.id,      stripeCustomerId: subscription.customer as string,      status: subscription.status,      currentPeriodStart: new Date(subscription.current_period_start * 1000),      currentPeriodEnd: new Date(subscription.current_period_end * 1000)    },    update: {      stripeSubscriptionId: subscription.id,      stripePriceId: subscription.items.data[0].price.id,      status: subscription.status,      currentPeriodStart: new Date(subscription.current_period_start * 1000),      currentPeriodEnd: new Date(subscription.current_period_end * 1000)    }  });}async function handleSubscriptionChange(subscription: Stripe.Subscription) {  const customerId = subscription.customer as string;  // Find user by Stripe customer ID  const user = await db.user.findFirst({    where: { stripeCustomerId: customerId }  });  if (!user) return;  await db.subscription.upsert({    where: { userId: user.id },    create: {      userId: user.id,      stripeSubscriptionId: subscription.id,      stripePriceId: subscription.items.data[0].price.id,      stripeCustomerId: customerId,      status: subscription.status,      currentPeriodStart: new Date(subscription.current_period_start * 1000),      currentPeriodEnd: new Date(subscription.current_period_end * 1000),      cancelAtPeriodEnd: subscription.cancel_at_period_end    },    update: {      stripePriceId: subscription.items.data[0].price.id,      status: subscription.status,      currentPeriodStart: new Date(subscription.current_period_start * 1000),      currentPeriodEnd: new Date(subscription.current_period_end * 1000),      cancelAtPeriodEnd: subscription.cancel_at_period_end    }  });}async function handleSubscriptionDeleted(subscription: Stripe.Subscription) {  await db.subscription.updateMany({    where: { stripeSubscriptionId: subscription.id },    data: { status: 'canceled' }  });}async function handleInvoicePaid(invoice: Stripe.Invoice) {  // Update subscription period dates  if (invoice.subscription) {    const subscription = await stripe.subscriptions.retrieve(      invoice.subscription as string    );    await db.subscription.updateMany({      where: { stripeSubscriptionId: subscription.id },      data: {        status: subscription.status,        currentPeriodStart: new Date(subscription.current_period_start * 1000),        currentPeriodEnd: new Date(subscription.current_period_end * 1000)      }    });  }}async function handleInvoiceFailed(invoice: Stripe.Invoice) {  // Send notification to user about failed payment  const customerId = invoice.customer as string;  const user = await db.user.findFirst({    where: { stripeCustomerId: customerId }  });  if (user) {    await sendEmail({      to: user.email,      subject: 'Payment Failed',      template: 'payment-failed',      data: {        invoiceUrl: invoice.hosted_invoice_url,        amount: (invoice.amount_due / 100).toFixed(2)      }    });  }}

Customer Portal

Let users manage their subscriptions through Stripe's hosted Customer Portal:

app/actions/portal.ts
'use server';import { redirect } from 'next/navigation';import { auth } from '@/lib/auth';import { stripe } from '@/lib/stripe';export async function createPortalSession() {  const session = await auth();  if (!session?.user?.id) {    redirect('/login');  }  const user = await db.user.findUnique({    where: { id: session.user.id },    select: { stripeCustomerId: true }  });  if (!user?.stripeCustomerId) {    redirect('/pricing');  }  const portalSession = await stripe.billingPortal.sessions.create({    customer: user.stripeCustomerId,    return_url: `${process.env.NEXT_PUBLIC_APP_URL}/billing`  });  redirect(portalSession.url);}

Subscription Management Actions

Create actions for common subscription operations:

app/actions/subscription.ts
'use server';import { revalidatePath } from 'next/cache';import { auth } from '@/lib/auth';import { stripe } from '@/lib/stripe';export async function cancelSubscription() {  const session = await auth();  if (!session?.user?.id) throw new Error('Unauthorized');  const subscription = await db.subscription.findUnique({    where: { userId: session.user.id }  });  if (!subscription) throw new Error('No subscription found');  // Cancel at period end (user keeps access until then)  await stripe.subscriptions.update(subscription.stripeSubscriptionId, {    cancel_at_period_end: true  });  revalidatePath('/billing');  return { success: true };}export async function resumeSubscription() {  const session = await auth();  if (!session?.user?.id) throw new Error('Unauthorized');  const subscription = await db.subscription.findUnique({    where: { userId: session.user.id }  });  if (!subscription) throw new Error('No subscription found');  await stripe.subscriptions.update(subscription.stripeSubscriptionId, {    cancel_at_period_end: false  });  revalidatePath('/billing');  return { success: true };}export async function changePlan(newPriceId: string) {  const session = await auth();  if (!session?.user?.id) throw new Error('Unauthorized');  const subscription = await db.subscription.findUnique({    where: { userId: session.user.id }  });  if (!subscription) throw new Error('No subscription found');  // Get current subscription from Stripe  const stripeSubscription = await stripe.subscriptions.retrieve(    subscription.stripeSubscriptionId  );  // Update the subscription with new price  await stripe.subscriptions.update(subscription.stripeSubscriptionId, {    items: [      {        id: stripeSubscription.items.data[0].id,        price: newPriceId      }    ],    proration_behavior: 'create_prorations'  });  revalidatePath('/billing');  return { success: true };}

One-Time Payments

For one-time purchases (like lifetime deals):

app/actions/one-time-payment.ts
'use server';import { redirect } from 'next/navigation';import { auth } from '@/lib/auth';import { stripe } from '@/lib/stripe';export async function createOneTimePayment(priceId: string) {  const session = await auth();  if (!session?.user?.id) {    redirect('/login');  }  const checkoutSession = await stripe.checkout.sessions.create({    mode: 'payment', // Not 'subscription'    payment_method_types: ['card'],    line_items: [      {        price: priceId,        quantity: 1      }    ],    success_url: `${process.env.NEXT_PUBLIC_APP_URL}/billing?success=true`,    cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,    customer_email: session.user.email!,    metadata: {      userId: session.user.id,      type: 'lifetime'    },    invoice_creation: {      enabled: true    }  });  redirect(checkoutSession.url!);}

Metered/Usage-Based Billing

For APIs or usage-based pricing:

lib/billing/usage.ts
import { stripe } from '@/lib/stripe';// Create a metered priceexport async function createMeteredPrice(productId: string) {  const price = await stripe.prices.create({    product: productId,    currency: 'usd',    recurring: {      interval: 'month',      usage_type: 'metered'    },    billing_scheme: 'tiered',    tiers_mode: 'graduated',    tiers: [      { up_to: 1000, unit_amount: 0 }, // First 1000 free      { up_to: 10000, unit_amount: 1 }, // $0.01 per unit      { up_to: 'inf', unit_amount: 0.5 } // $0.005 per unit after 10k    ]  });  return price;}// Report usage to Stripeexport async function reportUsage(  subscriptionItemId: string,  quantity: number) {  await stripe.subscriptionItems.createUsageRecord(subscriptionItemId, {    quantity,    timestamp: Math.floor(Date.now() / 1000),    action: 'increment'  });}// Example: Track API usageexport async function trackApiCall(userId: string) {  const subscription = await db.subscription.findUnique({    where: { userId },    include: { subscriptionItems: true }  });  if (!subscription) return;  const meteredItem = subscription.subscriptionItems.find(    (item) => item.type === 'metered'  );  if (meteredItem) {    await reportUsage(meteredItem.stripeItemId, 1);  }}

Billing Page Component

Display subscription status to users:

app/billing/page.tsx
import { redirect } from 'next/navigation';import { createPortalSession } from '@/app/actions/portal';import { Button } from '@/components/ui/button';import { auth } from '@/lib/auth';import { stripe } from '@/lib/stripe';export default async function BillingPage() {  const session = await auth();  if (!session?.user?.id) {    redirect('/login');  }  const subscription = await db.subscription.findUnique({    where: { userId: session.user.id }  });  // Get price details from Stripe  let priceDetails = null;  if (subscription?.stripePriceId) {    const price = await stripe.prices.retrieve(subscription.stripePriceId, {      expand: ['product']    });    priceDetails = price;  }  return (    <div className="container max-w-4xl py-10">      <h1 className="text-3xl font-bold mb-8">Billing</h1>      {subscription ? (        <div className="rounded-lg border p-6">          <div className="flex items-center justify-between mb-4">            <div>              <h2 className="text-xl font-semibold">                {(priceDetails?.product as any)?.name || 'Pro Plan'}              </h2>              <p className="text-muted-foreground">                {subscription.status === 'active'                  ? 'Active subscription'                  : `Status: ${subscription.status}`}              </p>            </div>            <div className="text-right">              <p className="text-2xl font-bold">                ${(priceDetails?.unit_amount || 0) / 100}                <span className="text-sm font-normal text-muted-foreground">                  /{priceDetails?.recurring?.interval}                </span>              </p>            </div>          </div>          {subscription.cancelAtPeriodEnd && (            <div className="bg-yellow-50 border border-yellow-200 rounded-md p-4 mb-4">              <p className="text-yellow-800">                Your subscription will cancel on{' '}                {subscription.currentPeriodEnd.toLocaleDateString()}              </p>            </div>          )}          <div className="flex gap-4">            <form action={createPortalSession}>              <Button                type="submit"                variant="outline"              >                Manage Subscription              </Button>            </form>          </div>          <div className="mt-6 pt-6 border-t">            <p className="text-sm text-muted-foreground">              Current period:{' '}              {subscription.currentPeriodStart.toLocaleDateString()}              {' - '}              {subscription.currentPeriodEnd.toLocaleDateString()}            </p>          </div>        </div>      ) : (        <div className="rounded-lg border p-6 text-center">          <h2 className="text-xl font-semibold mb-2">No active subscription</h2>          <p className="text-muted-foreground mb-4">            Choose a plan to get started          </p>          <Button asChild>            <a href="/pricing">View Plans</a>          </Button>        </div>      )}    </div>  );}

Testing

Use Stripe's test cards for development:

Card NumberScenario
4242424242424242Successful payment
4000000000000002Card declined
4000002500003155Requires 3D Secure
4000000000009995Insufficient funds

Test webhooks locally with Stripe CLI:

Terminal
# Install Stripe CLIbrew install stripe/stripe-cli/stripe# Loginstripe login# Forward webhooks to your local serverstripe listen --forward-to localhost:3000/api/webhooks/stripe# Trigger test eventsstripe trigger checkout.session.completedstripe trigger customer.subscription.updatedstripe trigger invoice.payment_failed

Production Best Practices

1. Store Billing Data Locally

Don't rely solely on Stripe API calls:

lib/subscription.ts
// Bad: Fetching from Stripe on every requestconst subscription = await stripe.subscriptions.retrieve(subId);// Good: Cache in your database, sync via webhooksconst subscription = await db.subscription.findUnique({  where: { stripeSubscriptionId: subId }});

2. Use Idempotency Keys

Prevent duplicate charges:

lib/payment.ts
await stripe.paymentIntents.create(  { amount: 1000, currency: 'usd' },  { idempotencyKey: `order_${orderId}` });

3. Handle Webhook Retries

Stripe retries failed webhooks for up to 3 days. Make your handlers idempotent:

lib/webhooks.ts
async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {  // Check if already processed  const existing = await db.subscription.findFirst({    where: { stripeSubscriptionId: session.subscription as string }  });  if (existing) {    console.log('Already processed, skipping');    return;  }  // Process the event...}

4. Secure Your Webhook Endpoint

Always verify signatures and use HTTPS in production.

Conclusion

Implementing Stripe billing in Next.js requires:

  1. Server Actions for secure server-side operations
  2. Webhooks to sync Stripe events to your database
  3. Customer Portal for self-service subscription management
  4. Proper error handling and idempotency

For a complete, production-ready implementation with all edge cases handled, check out our SaaS starter kits which include full Stripe integration with subscriptions, one-time payments, metered billing, and more.


Ready to skip the billing implementation headache? Our Prisma and Drizzle starter kits include complete Stripe integration out of the box. Get started today.