Skip to main content
Back to Blog
By Mahmut JomaaUpdated 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.

A payment card connected to a subscription cycle and database
On this page15 sections

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';

let stripeClient: Stripe | null = null;

export function getStripe(): Stripe {
  if (stripeClient) return stripeClient;

  const secretKey = process.env.STRIPE_SECRET_KEY;
  if (!secretKey) {
    throw new Error('STRIPE_SECRET_KEY is not set');
  }

  stripeClient = new Stripe(secretKey);
  return stripeClient;
}

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';

async function createProducts() {
  const secretKey = process.env.STRIPE_SECRET_KEY;
  if (!secretKey) throw new Error('STRIPE_SECRET_KEY is not set');
  const stripe = new Stripe(secretKey);

  // 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 { getStripe } from '@/lib/stripe';

export async function createCheckoutSession(priceId: string) {
  const session = await auth();

  if (!session?.user?.id) {
    redirect('/login');
  }

  const stripe = getStripe();

  // 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 { getStripe } 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 requestHeaders = await headers();
  const signature = requestHeaders.get('stripe-signature');

  if (!signature) {
    return NextResponse.json(
      { error: 'Missing stripe-signature header' },
      { status: 400 }
    );
  }

  const stripe = getStripe();
  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;

  const stripe = getStripe();
  // 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 stripe = getStripe();
    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 { getStripe } 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 stripe = getStripe();
  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 { getStripe } 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');

  const stripe = getStripe();
  // 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');

  const stripe = getStripe();
  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');

  const stripe = getStripe();
  // 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 { getStripe } from '@/lib/stripe';

export async function createOneTimePayment(priceId: string) {
  const session = await auth();

  if (!session?.user?.id) {
    redirect('/login');
  }

  const stripe = getStripe();
  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 { getStripe } from '@/lib/stripe';

// Create a meter and attach it to a recurring price
export async function createMeteredPrice(productId: string) {
  const stripe = getStripe();
  const meter = await stripe.billing.meters.create({
    display_name: 'API calls',
    event_name: 'api_call',
    default_aggregation: {
      formula: 'sum'
    },
    customer_mapping: {
      type: 'by_id',
      event_payload_key: 'stripe_customer_id'
    },
    value_settings: {
      event_payload_key: 'value'
    }
  });

  const price = await stripe.prices.create({
    product: productId,
    currency: 'usd',
    recurring: {
      interval: 'month',
      usage_type: 'metered',
      meter: meter.id
    },
    billing_scheme: 'tiered',
    tiers_mode: 'graduated',
    tiers: [
      { up_to: 1000, unit_amount: 0 }, // First 1000 free
      { up_to: 'inf', unit_amount: 1 } // $0.01 per unit after 1000
    ]
  });

  return price;
}

// Report usage to Stripe
export async function reportUsage(stripeCustomerId: string, quantity: number) {
  const stripe = getStripe();
  await stripe.billing.meterEvents.create({
    event_name: 'api_call',
    payload: {
      stripe_customer_id: stripeCustomerId,
      value: String(quantity)
    },
    identifier: crypto.randomUUID(),
    timestamp: Math.floor(Date.now() / 1000)
  });
}

// Example: Track API usage
export async function trackApiCall(userId: string) {
  const subscription = await db.subscription.findUnique({
    where: { userId }
  });

  if (subscription?.stripeCustomerId) {
    await reportUsage(subscription.stripeCustomerId, 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 { getStripe } from '@/lib/stripe';

export default async function BillingPage() {
  const session = await auth();

  if (!session?.user?.id) {
    redirect('/login');
  }

  const stripe = getStripe();
  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 maintained subscription, one-time payment, per-seat billing, and prepaid-credit implementations, review the SaaS starter kits and their billing documentation.


Ready to skip the billing implementation headache? Our Pro Prisma and Pro Drizzle starter kits include Stripe billing implementations and documentation. Compare the kits.