Back to Blog

Stripe for Next.js: Complete Integration Guide (Webhooks, Subscriptions, Payments)

DesignRevision Editorial DesignRevision Editorial · SaaS, frontend & developer tooling
24 min read
Human Written
Share:

Integrating Stripe with Next.js is one of the first major engineering decisions for any SaaS product. You need payments, subscriptions, webhooks, and a checkout flow that works on every device. But the combination of Next.js App Router architecture and Stripe's extensive API surface means there are dozens of ways to wire things up, and most tutorials only cover the basics.

This stripe nextjs guide covers the complete integration: from initial project setup to production-ready webhook handling, subscription management, and one-time payments. Whether you are building your first SaaS or migrating an existing billing system to Next.js, this guide provides the patterns and code architecture you need.

Stripe processes over $1 trillion in annual payment volume and powers billing for companies like Vercel, Notion, and OpenAI. Combined with Next.js, the most popular React framework for production applications, you get a billing stack that scales from your first customer to millions of transactions.

Key Takeaways

If you remember nothing else:

  • Stripe Checkout is the fastest path to accepting payments in Next.js. It handles PCI, mobile, localization, and tax automatically
  • Three packages are all you need: stripe, @stripe/stripe-js, and @stripe/react-stripe-js
  • Webhooks are essential. Process checkout.session.completed, invoice.paid, and customer.subscription.updated at minimum
  • Server Components handle secrets. Keep your Stripe secret key in Route Handlers and Server Actions only
  • Stripe CLI is required for local webhook testing. Run stripe listen during development
  • Use a SaaS starter template with pre-built Stripe billing to save 1 to 2 weeks of setup time

Table of Contents

  1. Setting Up Stripe in a Next.js Project
  2. Project Architecture for Stripe and Next.js
  3. Stripe Checkout: The Fastest Path to Payments
  4. Stripe Elements: Custom Payment Forms
  5. Webhook Handling in Next.js Route Handlers
  6. Subscription Management
  7. One-Time Payments
  8. The Stripe Customer Portal
  9. Server Components vs Client Components
  10. Testing Your Stripe Integration Locally
  11. Security Best Practices
  12. Error Handling Patterns
  13. SaaS Starter Templates with Stripe
  14. Common Mistakes and How to Avoid Them
  15. Conclusion

Setting Up Stripe in a Next.js Project

Every stripe nextjs integration starts with three packages and two environment variables. The setup takes about five minutes.

Install the Required Packages

You need three npm packages for a complete stripe nextjs setup:

  • stripe (v17+): The Node.js server SDK. Use this in Route Handlers and Server Actions for all API calls.
  • @stripe/stripe-js (v4.9+): The client-side loader. Initializes Stripe.js in the browser.
  • @stripe/react-stripe-js (v2.8+): React components for Stripe Elements. Optional if you only use Stripe Checkout.

Install them:

npm install stripe @stripe/stripe-js @stripe/react-stripe-js

Configure Environment Variables

Add these to your .env.local file:

STRIPE_SECRET_KEY=sk_test_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...

The STRIPE_SECRET_KEY is server-only. The NEXT_PUBLIC_ prefix makes the publishable key available to Client Components. The webhook secret verifies that incoming webhook events come from Stripe.

Create the Stripe Server Instance

Create a shared Stripe instance for server-side code:

// lib/stripe.ts
import Stripe from 'stripe';

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2025-10-29.acacia',
  typescript: true,
});

Pin the apiVersion to avoid breaking changes when Stripe releases new API versions. This is your single source of truth for all server-side Stripe operations in your stripe nextjs project.

Create the Client-Side Stripe Loader

Create a client-side loader for Stripe.js:

// lib/stripe-client.ts
import { loadStripe } from '@stripe/stripe-js';

export const stripePromise = loadStripe(
  process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!
);

Call loadStripe once and reuse the promise. This avoids loading Stripe.js multiple times.

Project Architecture for Stripe and Next.js

A well-structured stripe nextjs project separates server-side billing logic from client-side payment UI. Here is the recommended file structure for the App Router:

app/
  api/
    webhooks/
      stripe/
        route.ts          # Webhook handler
    create-checkout/
      route.ts            # Create Checkout Sessions
    create-portal/
      route.ts            # Create Customer Portal sessions
  (dashboard)/
    billing/
      page.tsx            # Billing management page
    subscription/
      page.tsx            # Subscription status page
  pricing/
    page.tsx              # Public pricing page
lib/
  stripe.ts               # Server-side Stripe instance
  stripe-client.ts        # Client-side Stripe loader
  stripe-helpers.ts       # Shared utility functions

The Data Flow

The billing data flow in a stripe nextjs application follows a consistent pattern:

User Action → Client Component → Route Handler → Stripe API → Webhook → Database → UI Update

Key principles:

  1. Stripe is the source of truth for all billing data. Never store subscription prices in your database.
  2. Webhooks sync state. Your database mirrors Stripe's subscription state through webhook events.
  3. Server-side only. All Stripe API calls with the secret key happen in Route Handlers or Server Actions.
  4. Checkout handles payment collection. Use Stripe Checkout unless you have a specific reason to build custom forms.

For a broader look at SaaS billing architecture beyond Next.js, see our SaaS Stripe integration guide.

Stripe Checkout: The Fastest Path to Payments

Stripe Checkout is the recommended approach for most stripe nextjs applications. It is a hosted payment page that Stripe optimizes continuously for conversion, handling PCI compliance, mobile responsiveness, and localization automatically.

Why Stripe Checkout

  • PCI compliance handled. Stripe manages all card data. You never touch sensitive payment information.
  • Mobile-optimized. Responsive design works across all devices without custom CSS.
  • Localized. Supports 40+ languages and displays local payment methods automatically.
  • Tax integration. Enable automatic_tax and Stripe calculates VAT, GST, and sales tax.
  • Higher conversion. Stripe Checkout converts up to 20% better than custom payment forms in their benchmarks.

Creating a Checkout Session

Create a Route Handler that generates Stripe Checkout Sessions:

// app/api/create-checkout/route.ts
import { stripe } from '@/lib/stripe';
import { NextResponse } from 'next/server';

export async function POST(req: Request) {
  try {
    const { priceId, customerId } = await req.json();

    const session = await stripe.checkout.sessions.create({
      mode: 'subscription',
      payment_method_types: ['card'],
      customer: customerId,
      line_items: [
        {
          price: priceId,
          quantity: 1,
        },
      ],
      subscription_data: {
        trial_period_days: 14,
      },
      automatic_tax: { enabled: true },
      allow_promotion_codes: true,
      success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?session_id={CHECKOUT_SESSION_ID}`,
      cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
    });

    return NextResponse.json({ url: session.url });
  } catch (error: any) {
    return NextResponse.json(
      { error: error.message },
      { status: 400 }
    );
  }
}

Redirecting to Checkout from the Client

On your pricing page, redirect users to the Checkout Session URL:

// components/PricingCard.tsx
'use client';

export function PricingCard({ priceId }: { priceId: string }) {
  const handleSubscribe = async () => {
    const response = await fetch('/api/create-checkout', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ priceId }),
    });

    const { url } = await response.json();
    window.location.href = url;
  };

  return (
    <button onClick={handleSubscribe}>
      Subscribe Now
    </button>
  );
}

This is the simplest and most effective pattern for accepting stripe subscriptions in a Next.js application. The user clicks "Subscribe," your server creates a Checkout Session, and the user completes payment on Stripe's hosted page.

For design guidance on building effective pricing pages that drive conversions, see our SaaS pricing page design guide.

Stripe Elements: Custom Payment Forms

Stripe Elements gives you full control over the payment form's look and feel. Use Elements when your checkout flow requires a deeply embedded experience that Stripe Checkout cannot support.

When to Use Stripe Elements

  • Multi-step onboarding where payment collection happens mid-flow
  • Custom branding requirements that demand full control over every pixel
  • Inline pricing configurators where the price changes based on user selections
  • Saved payment methods with a custom card management UI

Setting Up the Elements Provider

Wrap your payment form in the Elements provider:

// components/PaymentForm.tsx
'use client';

import { Elements } from '@stripe/react-stripe-js';
import { stripePromise } from '@/lib/stripe-client';
import { CheckoutForm } from './CheckoutForm';

export function PaymentForm({ clientSecret }: { clientSecret: string }) {
  return (
    <Elements
      stripe={stripePromise}
      options={{
        clientSecret,
        appearance: {
          theme: 'stripe',
          variables: {
            colorPrimary: '#0070f3',
          },
        },
      }}
    >
      <CheckoutForm />
    </Elements>
  );
}

Building the Checkout Form

// components/CheckoutForm.tsx
'use client';

import {
  PaymentElement,
  useStripe,
  useElements,
} from '@stripe/react-stripe-js';
import { useState } from 'react';

export function CheckoutForm() {
  const stripe = useStripe();
  const elements = useElements();
  const [isProcessing, setIsProcessing] = useState(false);
  const [errorMessage, setErrorMessage] = useState('');

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!stripe || !elements) return;

    setIsProcessing(true);
    setErrorMessage('');

    const { error } = await stripe.confirmPayment({
      elements,
      confirmParams: {
        return_url: `${window.location.origin}/dashboard`,
      },
    });

    if (error) {
      setErrorMessage(error.message || 'Payment failed.');
    }

    setIsProcessing(false);
  };

  return (
    <form onSubmit={handleSubmit}>
      <PaymentElement />
      <button type="submit" disabled={!stripe || isProcessing}>
        {isProcessing ? 'Processing...' : 'Pay Now'}
      </button>
      {errorMessage && <p>{errorMessage}</p>}
    </form>
  );
}

The PaymentElement component renders a complete payment form that supports cards, Apple Pay, Google Pay, and other payment methods based on the customer's location. It replaces the older CardElement and handles SCA (Strong Customer Authentication) automatically.

Stripe Checkout vs Stripe Elements: When to Use Which

Criteria Stripe Checkout Stripe Elements
Setup time Minutes Hours to days
PCI compliance Automatic Automatic (with Elements)
Customization Limited (branding, colors) Full control
Mobile optimization Built-in You handle it
Conversion rate Higher (Stripe-optimized) Depends on implementation
Tax handling Built-in with automatic_tax Manual integration
Best for Most SaaS applications Complex custom flows

For 90% of stripe nextjs applications, Stripe Checkout is the right choice. Only reach for Elements when Checkout genuinely cannot support your checkout flow.

Webhook Handling in Next.js Route Handlers

Stripe webhooks are the backbone of your billing system. They notify your application when payment events occur so you can update subscription state, provision access, and trigger business logic. Getting stripe webhooks right is critical for a reliable billing integration.

Creating the Webhook Route Handler

// app/api/webhooks/stripe/route.ts
import { stripe } from '@/lib/stripe';
import { headers } from 'next/headers';
import { NextResponse } from 'next/server';

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

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

  let event;

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

  try {
    switch (event.type) {
      case 'checkout.session.completed':
        await handleCheckoutCompleted(event.data.object);
        break;
      case 'invoice.paid':
        await handleInvoicePaid(event.data.object);
        break;
      case 'invoice.payment_failed':
        await handlePaymentFailed(event.data.object);
        break;
      case 'customer.subscription.updated':
        await handleSubscriptionUpdated(event.data.object);
        break;
      case 'customer.subscription.deleted':
        await handleSubscriptionDeleted(event.data.object);
        break;
    }
  } catch (err) {
    console.error('Webhook handler error:', err);
    return NextResponse.json(
      { error: 'Webhook handler failed' },
      { status: 500 }
    );
  }

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

Essential Stripe Webhooks for SaaS

Event When It Fires What to Do
checkout.session.completed Customer completes checkout Activate subscription, provision access
invoice.paid Subscription renews successfully Update current period, confirm access
invoice.payment_failed Payment attempt fails Start dunning flow, notify customer
customer.subscription.updated Plan change or cancellation scheduled Update plan and status in database
customer.subscription.deleted Subscription fully canceled Revoke access, trigger offboarding
customer.subscription.trial_will_end 3 days before trial ends Send conversion email

Webhook Handler Functions

async function handleCheckoutCompleted(
  session: Stripe.Checkout.Session
) {
  const customerId = session.customer as string;
  const subscriptionId = session.subscription as string;

  // Fetch the full subscription details
  const subscription = await stripe.subscriptions.retrieve(subscriptionId);

  // Update your database
  await db.user.update({
    where: { stripeCustomerId: customerId },
    data: {
      subscriptionId: subscription.id,
      subscriptionStatus: subscription.status,
      priceId: subscription.items.data[0].price.id,
      currentPeriodEnd: new Date(subscription.current_period_end * 1000),
    },
  });
}

async function handleInvoicePaid(invoice: Stripe.Invoice) {
  const subscriptionId = invoice.subscription as string;
  const subscription = await stripe.subscriptions.retrieve(subscriptionId);

  await db.user.update({
    where: { stripeCustomerId: invoice.customer as string },
    data: {
      subscriptionStatus: 'active',
      currentPeriodEnd: new Date(subscription.current_period_end * 1000),
    },
  });
}

async function handlePaymentFailed(invoice: Stripe.Invoice) {
  await db.user.update({
    where: { stripeCustomerId: invoice.customer as string },
    data: {
      subscriptionStatus: 'past_due',
    },
  });

  // Trigger dunning email flow
}

async function handleSubscriptionUpdated(
  subscription: Stripe.Subscription
) {
  await db.user.update({
    where: { stripeCustomerId: subscription.customer as string },
    data: {
      subscriptionStatus: subscription.status,
      priceId: subscription.items.data[0].price.id,
      cancelAtPeriodEnd: subscription.cancel_at_period_end,
    },
  });
}

async function handleSubscriptionDeleted(
  subscription: Stripe.Subscription
) {
  await db.user.update({
    where: { stripeCustomerId: subscription.customer as string },
    data: {
      subscriptionStatus: 'canceled',
      subscriptionId: null,
      priceId: null,
    },
  });
}

Webhook Security Rules

  1. Always verify signatures. Never process a webhook event without calling constructEvent().
  2. Process idempotently. Stripe may send the same event multiple times. Use the event id to avoid duplicate processing.
  3. Return 200 quickly. If your logic takes more than a few seconds, acknowledge the webhook immediately and process asynchronously with a job queue.
  4. Use HTTPS in production. Stripe will not deliver webhooks to insecure endpoints.
  5. Handle out-of-order events. Check the created timestamp to process events in the correct order.

For authentication patterns that integrate with webhook-driven subscription state, see our SaaS authentication guide.

Subscription Management

Subscription lifecycle management is where your stripe nextjs integration handles the real complexity. Customers upgrade, downgrade, pause, and cancel. Each action requires specific Stripe API calls and database updates.

Creating Subscriptions via Checkout

The simplest approach is creating subscriptions through Stripe Checkout Sessions with mode: 'subscription'. This handles payment collection, trial setup, and the initial subscription creation in one flow.

Handling Upgrades and Downgrades

Upgrades should apply immediately with proration:

// app/api/subscription/upgrade/route.ts
import { stripe } from '@/lib/stripe';
import { NextResponse } from 'next/server';

export async function POST(req: Request) {
  const { subscriptionId, newPriceId } = await req.json();

  const subscription = await stripe.subscriptions.retrieve(subscriptionId);

  const updatedSubscription = await stripe.subscriptions.update(
    subscriptionId,
    {
      items: [
        {
          id: subscription.items.data[0].id,
          price: newPriceId,
        },
      ],
      proration_behavior: 'create_prorations',
    }
  );

  return NextResponse.json(updatedSubscription);
}

Downgrades should apply at the end of the current billing period to avoid issuing refunds:

const updatedSubscription = await stripe.subscriptions.update(
  subscriptionId,
  {
    items: [
      {
        id: subscription.items.data[0].id,
        price: newPriceId,
      },
    ],
    proration_behavior: 'none',
  }
);

Cancellation Handling

Use end-of-period cancellation as the default. It is less abrupt and gives customers time to reconsider:

// Cancel at end of billing period
await stripe.subscriptions.update(subscriptionId, {
  cancel_at_period_end: true,
});

// Immediate cancellation (use sparingly)
await stripe.subscriptions.cancel(subscriptionId);

Subscription State Machine

State Meaning Feature Access
trialing In free trial Full access
active Paying customer Full access
past_due Payment failed, retrying Degraded or full access
canceled Subscription ended No access
unpaid All retries exhausted No access
paused Customer-initiated pause No access
incomplete Initial payment failed No access

Map these states to feature flags in your application. Check the subscription status on every authenticated request or cache it with a reasonable TTL.

For a deeper dive into pricing models and subscription architecture, see our SaaS billing system guide.

One-Time Payments

Not every payment in a stripe nextjs application is a subscription. You may need one-time payments for credits, add-ons, lifetime deals, or individual product purchases.

Using Checkout for One-Time Payments

// app/api/create-payment/route.ts
import { stripe } from '@/lib/stripe';
import { NextResponse } from 'next/server';

export async function POST(req: Request) {
  const { priceId } = await req.json();

  const session = await stripe.checkout.sessions.create({
    mode: 'payment',
    payment_method_types: ['card'],
    line_items: [
      {
        price: priceId,
        quantity: 1,
      },
    ],
    success_url: `${process.env.NEXT_PUBLIC_APP_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
  });

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

Using Payment Intents for Custom Flows

For custom payment flows with Stripe Elements, create a Payment Intent server-side and confirm it client-side:

// app/api/create-payment-intent/route.ts
import { stripe } from '@/lib/stripe';
import { NextResponse } from 'next/server';

export async function POST(req: Request) {
  const { amount, currency = 'usd' } = await req.json();

  const paymentIntent = await stripe.paymentIntents.create({
    amount, // Amount in cents
    currency,
    automatic_payment_methods: { enabled: true },
  });

  return NextResponse.json({
    clientSecret: paymentIntent.client_secret,
  });
}

Pass the clientSecret to your Stripe Elements component to complete the payment on the client side.

The Stripe Customer Portal

The Stripe Customer Portal is a hosted UI where customers manage their subscriptions without you building custom management screens. It handles payment method updates, plan changes, invoice history, and cancellations.

Setting Up the Portal

Create a Route Handler that generates portal sessions:

// app/api/create-portal/route.ts
import { stripe } from '@/lib/stripe';
import { NextResponse } from 'next/server';

export async function POST(req: Request) {
  const { customerId } = await req.json();

  const session = await stripe.billingPortal.sessions.create({
    customer: customerId,
    return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard`,
  });

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

What the Portal Handles

Feature Benefit
Update payment method Customers replace cards without contacting support
View invoices Download PDF invoices for accounting
Upgrade or downgrade Switch between plans you configure
Cancel subscription Self-service with optional retention offers
Update billing info Change name, email, tax ID

The Customer Portal eliminates 70%+ of subscription management code in your stripe nextjs application. Configure available actions in the Stripe Dashboard under Settings > Customer Portal.

Server Components vs Client Components

The Next.js App Router's server/client component model maps naturally to Stripe's security architecture. Understanding this boundary is essential for a secure stripe nextjs integration.

What Goes Where

Operation Component Type Why
Create Checkout Sessions Server (Route Handler) Requires secret key
Process webhooks Server (Route Handler) Requires secret key + signature
Manage subscriptions Server (Route Handler/Action) Requires secret key
Create Portal sessions Server (Route Handler) Requires secret key
Load Stripe.js Client Component Browser-only API
Render Elements Client Component DOM interaction
Redirect to Checkout Client Component Browser navigation

Server Actions for Stripe

Next.js Server Actions provide a clean way to call Stripe from Client Components without creating separate API routes:

// app/actions/stripe.ts
'use server';

import { stripe } from '@/lib/stripe';

export async function createCheckoutSession(priceId: string) {
  const session = await stripe.checkout.sessions.create({
    mode: 'subscription',
    line_items: [{ price: priceId, quantity: 1 }],
    success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard`,
    cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
  });

  return session.url;
}
// components/SubscribeButton.tsx
'use client';

import { createCheckoutSession } from '@/app/actions/stripe';

export function SubscribeButton({ priceId }: { priceId: string }) {
  const handleClick = async () => {
    const url = await createCheckoutSession(priceId);
    if (url) window.location.href = url;
  };

  return <button onClick={handleClick}>Subscribe</button>;
}

This pattern keeps all Stripe secret key operations on the server while providing a clean interface for Client Components.

Testing Your Stripe Integration Locally

Testing stripe webhooks locally requires the Stripe CLI. Without it, you cannot verify that your webhook handlers process events correctly.

Setting Up the Stripe CLI

  1. Install the Stripe CLI from stripe.com/docs/stripe-cli
  2. Log in with stripe login
  3. Forward events to your local server:
stripe listen --forward-to localhost:3000/api/webhooks/stripe

The CLI outputs a webhook signing secret (starting with whsec_). Use this as your STRIPE_WEBHOOK_SECRET during local development.

Test Card Numbers

Card Number Result
4242 4242 4242 4242 Successful payment
4000 0000 0000 0002 Card declined
4000 0025 0000 3155 Requires 3D Secure
4000 0000 0000 9995 Insufficient funds

Triggering Test Events

The Stripe CLI lets you trigger specific webhook events for testing:

# Trigger a checkout completion
stripe trigger checkout.session.completed

# Trigger a payment failure
stripe trigger invoice.payment_failed

# Trigger a subscription update
stripe trigger customer.subscription.updated

Testing Checklist for Stripe and Next.js

Test Case How to Verify
Successful subscription Complete checkout with test card 4242
Failed payment Use declined card 4000 0000 0000 0002
Subscription upgrade Change plan via API or Portal
Subscription cancellation Cancel via Portal, verify webhook
Webhook signature verification Send invalid signature, expect 400
Trial expiration Set 1-day trial, wait for webhook
Customer Portal access Create portal session, verify redirect

Security Best Practices

A production stripe nextjs integration requires careful attention to security. Payment processing is a high-value target, and mistakes can expose customer data or enable fraud.

Environment Variable Security

  • Never commit API keys. Add .env.local to .gitignore.
  • Use restricted API keys in production. Create keys with only the permissions your application needs.
  • Rotate keys periodically. Stripe lets you roll API keys without downtime.

Webhook Security

  • Verify every signature. Use stripe.webhooks.constructEvent() on every incoming webhook.
  • Process idempotently. Use the event id to prevent duplicate processing.
  • Use HTTPS only. Stripe will not deliver webhooks to HTTP endpoints in production.

Content Security Policy

Add Stripe's domains to your Content Security Policy:

script-src 'self' js.stripe.com;
frame-src js.stripe.com hooks.stripe.com;
connect-src api.stripe.com;

Stripe Radar for Fraud Prevention

Enable Stripe Radar to block fraudulent transactions. Radar uses machine learning trained on data from millions of Stripe merchants to identify and block fraud in real time. It is included with every Stripe account at no additional cost for the basic tier.

Security Checklist

Item Priority
Webhook signatures verified on every event Required
API keys stored in environment variables only Required
Restricted API keys in production Recommended
Stripe.js loaded from js.stripe.com (never self-hosted) Required
Customer card data never touches your servers Required
CSP headers include Stripe domains Recommended
Idempotency keys used on create/update calls Recommended
Stripe Radar enabled Recommended

For a comprehensive security checklist covering your entire SaaS stack, see our SaaS security checklist.

Error Handling Patterns

Robust error handling separates a prototype from a production stripe nextjs application. Stripe errors come in predictable categories that you can handle gracefully.

Server-Side Error Handling

import Stripe from 'stripe';
import { NextResponse } from 'next/server';

export async function POST(req: Request) {
  try {
    const session = await stripe.checkout.sessions.create({
      // ... session config
    });
    return NextResponse.json({ url: session.url });
  } catch (error) {
    if (error instanceof Stripe.errors.StripeCardError) {
      return NextResponse.json(
        { error: 'Card was declined.' },
        { status: 400 }
      );
    }
    if (error instanceof Stripe.errors.StripeInvalidRequestError) {
      return NextResponse.json(
        { error: 'Invalid request parameters.' },
        { status: 400 }
      );
    }
    if (error instanceof Stripe.errors.StripeAuthenticationError) {
      console.error('Stripe API key is invalid');
      return NextResponse.json(
        { error: 'Payment service configuration error.' },
        { status: 500 }
      );
    }
    return NextResponse.json(
      { error: 'An unexpected error occurred.' },
      { status: 500 }
    );
  }
}

Client-Side Error Handling

When using Stripe Elements, handle errors from confirmPayment:

const { error, paymentIntent } = await stripe.confirmPayment({
  elements,
  confirmParams: {
    return_url: `${window.location.origin}/success`,
  },
  redirect: 'if_required',
});

if (error) {
  switch (error.type) {
    case 'card_error':
      setMessage(error.message || 'Your card was declined.');
      break;
    case 'validation_error':
      setMessage('Please check your payment details.');
      break;
    default:
      setMessage('Something went wrong. Please try again.');
  }
}

Common Stripe Errors in Next.js

Error Cause Solution
Webhook signature mismatch Wrong signing secret or body parsing Use req.text() for raw body
resource_missing Invalid Price or Customer ID Verify IDs exist in Stripe Dashboard
Rate limit exceeded Too many API calls Implement backoff and caching
Timeout on Vercel Webhook handler too slow Process asynchronously with a job queue
Hydration mismatch Stripe Elements in Server Component Wrap in Client Component with use client

SaaS Starter Templates with Stripe

Building a complete stripe nextjs integration from scratch takes 1 to 2 weeks. SaaS starter templates eliminate the bulk of this work by providing pre-configured billing infrastructure.

What a Good Template Includes

Feature Why It Matters
Checkout Session creation Handles plan selection and payment
Webhook processing Syncs subscription state to database
Customer Portal integration Self-service subscription management
Subscription state management Access control based on plan
Pricing page component Displays plans with monthly/annual toggle
Free trial support Trial period and conversion tracking

Recommended Approaches

Forge generates complete Next.js SaaS applications with Stripe billing pre-configured. Describe your product and pricing model, and Forge produces production-ready code with Checkout Sessions, webhook handlers, and Customer Portal integration. You own the code and deploy anywhere.

Approach Time to Launch Cost Flexibility
Custom build 1-2 weeks Engineer salary Maximum
SaaS template 1-2 days $99-299 one-time High
AI-generated (Forge) Hours Starting at $20/mo High
No-code platform Days $25-119/mo Limited

For a detailed comparison of template options, see our best Next.js SaaS templates guide and best Next.js subscription templates. If you prefer a pre-built kit, our best SaaS starter kits guide ranks the top options.

Common Mistakes and How to Avoid Them

These are the most frequent stripe nextjs integration mistakes based on common developer issues.

1. Parsing the Webhook Body as JSON

The mistake: Using req.json() in the webhook handler instead of req.text().

Why it breaks: Stripe webhook signature verification requires the raw request body as a string. Parsing it as JSON first changes the string representation and causes signature verification to fail every time.

The fix: Always use const body = await req.text() in your webhook Route Handler.

2. Exposing the Secret Key to the Client

The mistake: Using NEXT_PUBLIC_STRIPE_SECRET_KEY or importing the server Stripe instance in a Client Component.

Why it breaks: The secret key gets bundled into client-side JavaScript, visible to anyone who views the page source. This gives attackers full access to your Stripe account.

The fix: Only use NEXT_PUBLIC_ for the publishable key. Keep the secret key in server-side code only.

3. Not Handling Subscription State Changes via Webhooks

The mistake: Only checking subscription status during checkout and assuming it stays active.

Why it breaks: Subscriptions can be canceled, paused, or fail payment renewal at any time. Without webhook handlers, your application shows active access to users who are no longer paying.

The fix: Implement webhook handlers for customer.subscription.updated and customer.subscription.deleted. Sync subscription state to your database on every change.

4. Building Custom Subscription Management UI

The mistake: Building pages for plan changes, payment method updates, invoice history, and cancellation from scratch.

Why it breaks: It takes weeks to build and maintain, and it is never as good as what Stripe provides. Every Stripe feature update requires changes to your custom UI.

The fix: Use the Stripe Customer Portal. It handles 90% of subscription management needs with zero custom code.

5. Running Webhook Handlers on Edge Runtime

The mistake: Deploying webhook handlers to Vercel Edge Functions.

Why it breaks: The stripe Node.js SDK requires the Node.js runtime. Edge Functions have limited Node.js API support and may not handle webhook signature verification correctly.

The fix: Add export const runtime = 'nodejs' to your webhook Route Handler file or use the default Node.js runtime.

Conclusion

A stripe nextjs integration follows a clear pattern: set up the server SDK, create Checkout Sessions for payment collection, process webhooks for state synchronization, and use the Customer Portal for subscription management. The architecture maps naturally to Next.js App Router's server/client component model.

Start with Stripe Checkout. It handles PCI compliance, mobile optimization, and payment collection better than any custom form. Add Stripe Elements later only if Checkout cannot support your specific flow.

Get webhooks right. Verify signatures, process idempotently, and handle the essential events: checkout.session.completed, invoice.paid, invoice.payment_failed, and customer.subscription.updated. This covers 90% of billing scenarios.

Use the Customer Portal. It eliminates weeks of UI development for subscription management, payment updates, and invoice access.

Save time with templates. Use Forge or a SaaS starter template to skip the boilerplate. The patterns are well-established, and there is no reason to build them from scratch for most projects.

For the full SaaS billing architecture that complements this stripe nextjs guide, see our SaaS Stripe integration guide and SaaS billing system guide. If you are choosing between payment platforms, our Stripe vs Paddle and Stripe vs Lemon Squeezy comparisons cover the tradeoffs. And if you are building your first SaaS from scratch, our how to build a SaaS guide covers the complete Next.js and Supabase stack.


Related Resources

Frequently Asked Questions

Create a Route Handler at app/api/webhooks/stripe/route.ts. Read the raw request body using req.text(), then verify the webhook signature with stripe.webhooks.constructEvent() using your STRIPE_WEBHOOK_SECRET. Always return a 200 response quickly and process events asynchronously if the logic takes more than a few seconds. For local development, use the Stripe CLI with stripe listen to forward events to localhost:3000/api/webhooks/stripe. The essential events for SaaS are checkout.session.completed, invoice.paid, invoice.payment_failed, and customer.subscription.updated.

Use Stripe Checkout for most Next.js SaaS applications. Checkout is a hosted payment page that handles PCI compliance, mobile responsiveness, localization, tax calculation, and payment method display automatically. Stripe reports that Checkout converts up to 20% better than custom forms because they optimize the experience continuously. Use Stripe Elements only when you need a deeply embedded checkout flow with custom branding that cannot be achieved with Checkout, such as inline payment forms within multi-step onboarding flows.

Use Stripe test mode keys (starting with pk_test_ and sk_test_) in your environment variables. Install the Stripe CLI and run stripe listen --forward-to localhost:3000/api/webhooks/stripe to forward webhook events to your local development server. Use test card numbers like 4242 4242 4242 4242 for successful payments and 4000 0000 0000 0002 for declined cards. The Stripe CLI also lets you trigger specific events with stripe trigger checkout.session.completed to test your webhook handlers without completing a real checkout flow.

Install three packages: stripe (the Node.js server SDK for API calls in Route Handlers and Server Actions), @stripe/stripe-js (the client-side loader for Stripe.js), and @stripe/react-stripe-js (React components for Stripe Elements). For most projects using Stripe Checkout, you only need stripe and @stripe/stripe-js since Elements are optional. Keep packages updated to the latest versions to access new Stripe API features and security patches.

Create subscriptions through Stripe Checkout Sessions with mode set to subscription. Handle the subscription lifecycle via webhooks: listen for customer.subscription.created, customer.subscription.updated, and customer.subscription.deleted events to sync subscription state to your database. Use stripe.subscriptions.update() for plan changes with proration, and stripe.subscriptions.update() with cancel_at_period_end set to true for cancellations. Set up the Stripe Customer Portal so users can manage billing, update payment methods, and switch plans without you building a custom UI.

Keep all Stripe secret key operations in Server Components, Server Actions, or Route Handlers. This includes creating Checkout Sessions, managing subscriptions, and processing webhooks. Client Components (marked with use client) handle Stripe.js initialization with the publishable key and Stripe Elements rendering. Never expose your Stripe secret key to the client. Pass only the Checkout Session URL or client_secret from server to client when needed for payment confirmation.

Stripe charges 2.9% plus $0.30 per successful card payment for standard processing. Stripe Billing adds 0.7% per recurring transaction for subscription management features. Stripe Tax adds 0.5% per transaction for automatic tax calculation. For a SaaS product charging $50 per month per customer, total Stripe fees are approximately $2.35 per transaction (about 4.7% effective rate). There are no Next.js-specific costs. Volume discounts are available for businesses processing over $100K per month.

Yes. Next.js with Supabase and Stripe is the most popular SaaS stack in 2026. Use Next.js App Router for the frontend and API routes, Supabase for the database and authentication, and Stripe for billing. Create Stripe Checkout Sessions in Next.js Route Handlers, sync subscription data to Supabase via webhooks, and enforce access control with Supabase Row Level Security based on subscription status. Several SaaS starter templates including Forge come with this stack pre-configured.

Join 50k+ subscribers

Web dev, SaaS, growth & marketing. Weekly.

Thanks for subscribing! Check your email.

No spam, unsubscribe anytime.