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:
pnpm install stripe @stripe/stripe-jsCreate your environment variables:
# 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:3000Server-Side Stripe Client
Create a singleton Stripe instance for server-side operations:
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:
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:
'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:
'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:
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:
'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:
'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):
'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:
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:
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 Number | Scenario |
|---|---|
4242424242424242 | Successful payment |
4000000000000002 | Card declined |
4000002500003155 | Requires 3D Secure |
4000000000009995 | Insufficient funds |
Test webhooks locally with Stripe CLI:
# 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_failedProduction Best Practices
1. Store Billing Data Locally
Don't rely solely on Stripe API calls:
// 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:
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:
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:
- Server Actions for secure server-side operations
- Webhooks to sync Stripe events to your database
- Customer Portal for self-service subscription management
- 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.
Related Articles
- Building a SaaS Dashboard with React Server Components - Learn how to build performant dashboards that complement your billing system
- Multi-Tenant Architecture in Next.js - Implement organization-based billing with multi-tenancy
- Prisma vs Drizzle ORM - Choose the right database layer for your billing data
Ready to skip the billing implementation headache? Our Prisma and Drizzle starter kits include complete Stripe integration out of the box. Get started today.