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 Keys
STRIPE_SECRET_KEY=sk_test_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...

# Your app URL
NEXT_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 price
export 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 Stripe
export async function reportUsage(
  subscriptionItemId: string,
  quantity: number
) {
  await stripe.subscriptionItems.createUsageRecord(subscriptionItemId, {
    quantity,
    timestamp: Math.floor(Date.now() / 1000),
    action: 'increment'
  });
}

// Example: Track API usage
export 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 CLI
brew install stripe/stripe-cli/stripe

# Login
stripe login

# Forward webhooks to your local server
stripe listen --forward-to localhost:3000/api/webhooks/stripe

# Trigger test events
stripe trigger checkout.session.completed
stripe trigger customer.subscription.updated
stripe 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 request
const subscription = await stripe.subscriptions.retrieve(subId);

// Good: Cache in your database, sync via webhooks
const 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.