Skip to main content

Stripe Next.js App Router Integration Guide (2026)

·APIScout Team
Share:

Stripe + Next.js App Router: Complete Integration Guide (2026)

Stripe is the most popular payment API for developers, handling billions of dollars in transactions annually across millions of applications. Its popularity isn't accidental: Stripe has genuinely excellent documentation, TypeScript support, a developer-friendly test mode, and a surface area that scales from a simple one-time charge to a full subscription billing system with dunning, invoicing, and tax collection.

This guide covers a complete Next.js integration using the App Router. You'll build one-time payments with Checkout Sessions, recurring subscription billing, webhook event handling, and a customer portal — plus the production checklist you need before flipping to live keys. Each section builds on the previous, so you can stop at the level your product needs.

Why Stripe over alternatives? Stripe's test mode mirrors production behavior exactly, including webhook events. Its TypeScript types are hand-maintained by their SDK team (not auto-generated). And the Checkout Session flow handles PCI compliance, SCA (Strong Customer Authentication), and tax collection automatically — saving weeks of compliance work.

What You'll Build

  • One-time payment flow (Stripe Checkout)
  • Subscription billing (monthly/yearly plans)
  • Webhook handler for payment events
  • Customer portal for managing subscriptions

Prerequisites: Next.js 14+, Node.js 18+, Stripe account (free to create).

Understanding Stripe's Core Objects

Before writing any code, it helps to understand the main Stripe objects you'll interact with. Confusing these is the most common source of integration bugs.

  • Customer — a Stripe entity representing a paying user. Store the Stripe Customer ID (cus_xxx) alongside your user record in your database. One Stripe Customer can have multiple payment methods and subscriptions.
  • Product — what you're selling (e.g., "Pro Plan"). Products are containers; they don't have prices attached directly.
  • Price — a specific billing configuration for a Product (e.g., "$29/month" or "$290/year"). One Product can have many Prices.
  • Checkout Session — a hosted payment page Stripe generates. You create a Session server-side, redirect the user to it, and Stripe handles the payment form, card validation, 3D Secure, and PCI compliance.
  • Subscription — a recurring billing relationship between a Customer and a Price. Stripe automatically generates Invoices each billing period and retries failed payments.
  • Invoice — generated automatically for subscriptions. Contains one or more Invoice Items and has a status: draft, open, paid, void, or uncollectible.
  • Webhook Event — a POST request Stripe sends to your server when something happens (payment succeeds, subscription renews, payment fails). Webhooks are the authoritative notification mechanism — don't rely on redirect URLs for order fulfillment.

1. Setup

Install Dependencies

npm install stripe @stripe/stripe-js

Environment Variables

# .env.local
STRIPE_SECRET_KEY=sk_test_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...

Never expose your secret key. The publishable key (pk_) is safe for the browser. The secret key (sk_) stays on the server only.

Initialize Stripe

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

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

2. One-Time Payments (Checkout Session)

Create Checkout Session (API Route)

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

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_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${process.env.NEXT_PUBLIC_URL}/pricing`,
  });

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

Trigger Checkout (Client)

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

export function CheckoutButton({ priceId }: { priceId: string }) {
  const handleCheckout = async () => {
    const res = await fetch('/api/checkout', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ priceId }),
    });
    const { url } = await res.json();
    window.location.href = url;
  };

  return <button onClick={handleCheckout}>Buy Now</button>;
}

Success Page

// app/success/page.tsx
import { stripe } from '@/lib/stripe';

export default async function SuccessPage({
  searchParams,
}: {
  searchParams: { session_id: string };
}) {
  const session = await stripe.checkout.sessions.retrieve(
    searchParams.session_id
  );

  return (
    <div>
      <h1>Payment Successful!</h1>
      <p>Amount: ${(session.amount_total! / 100).toFixed(2)}</p>
      <p>Email: {session.customer_details?.email}</p>
    </div>
  );
}

3. Subscription Billing

Subscriptions in Stripe have a well-defined lifecycle: trialingactivepast_duecanceled (or unpaid). When a subscription payment fails, Stripe enters a dunning period: it retries the payment automatically on a configurable schedule (default: 3 retries over 7 days). During the past_due period, your application can choose to keep the user's access active (grace period) or immediately downgrade them. Most SaaS products implement a short grace period (3-7 days) before revoking access.

Stripe's Smart Retries use machine learning to retry failed payments at the time most likely to succeed based on historical card behavior. You can configure the retry schedule and customize dunning behavior in the Stripe Dashboard under Billing → Settings.

For subscription products, always collect a payment method upfront even if you offer a free trial — it dramatically reduces trial-to-paid conversion friction and prevents the "I forgot to add my card after the trial" churn category.

Create Subscription Checkout

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

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

  const session = await stripe.checkout.sessions.create({
    mode: 'subscription',
    customer: customerId, // Optional: link to existing customer
    line_items: [{ price: priceId, quantity: 1 }],
    success_url: `${process.env.NEXT_PUBLIC_URL}/dashboard?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${process.env.NEXT_PUBLIC_URL}/pricing`,
  });

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

Create Products and Prices in Stripe

Set up your products in the Stripe Dashboard or via API:

// One-time setup script
const product = await stripe.products.create({
  name: 'Pro Plan',
  description: 'Full access to all features',
});

const monthlyPrice = await stripe.prices.create({
  product: product.id,
  unit_amount: 2900, // $29.00
  currency: 'usd',
  recurring: { interval: 'month' },
});

const yearlyPrice = await stripe.prices.create({
  product: product.id,
  unit_amount: 29000, // $290.00 (save $58)
  currency: 'usd',
  recurring: { interval: 'year' },
});

Customer Portal

Let customers manage their own subscriptions:

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

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_URL}/dashboard`,
  });

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

4. Webhook Handler

Webhooks are how Stripe tells your app about payment events. This is the most critical piece — without webhooks, you won't know when payments succeed or fail.

A common mistake is fulfilling orders in the success page redirect handler. That's unreliable because the user might close the browser tab before the redirect fires. Always use webhooks for order fulfillment — the success page is only for UX feedback.

Create Webhook Route

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

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

  let event: Stripe.Event;

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

  switch (event.type) {
    case 'checkout.session.completed': {
      const session = event.data.object as Stripe.Checkout.Session;
      // Fulfill the order — update database, send email, etc.
      await handleCheckoutComplete(session);
      break;
    }

    case 'invoice.payment_succeeded': {
      const invoice = event.data.object as Stripe.Invoice;
      // Subscription renewed — extend access
      await handleSubscriptionRenewal(invoice);
      break;
    }

    case 'invoice.payment_failed': {
      const invoice = event.data.object as Stripe.Invoice;
      // Payment failed — notify customer, maybe downgrade
      await handlePaymentFailed(invoice);
      break;
    }

    case 'customer.subscription.deleted': {
      const subscription = event.data.object as Stripe.Subscription;
      // Subscription cancelled — revoke access
      await handleSubscriptionCancelled(subscription);
      break;
    }
  }

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

Test Webhooks Locally

# 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 invoice.payment_succeeded

5. Pricing Page

// app/pricing/page.tsx
import { CheckoutButton } from '@/components/CheckoutButton';

const plans = [
  {
    name: 'Starter',
    price: '$0',
    priceId: null,
    features: ['100 API calls/month', 'Basic support'],
  },
  {
    name: 'Pro',
    price: '$29/mo',
    priceId: 'price_pro_monthly',
    features: ['10,000 API calls/month', 'Priority support', 'Webhooks'],
  },
  {
    name: 'Enterprise',
    price: '$99/mo',
    priceId: 'price_enterprise_monthly',
    features: ['Unlimited API calls', '24/7 support', 'SLA', 'SSO'],
  },
];

export default function PricingPage() {
  return (
    <div className="grid grid-cols-3 gap-8">
      {plans.map((plan) => (
        <div key={plan.name} className="border rounded-lg p-6">
          <h3>{plan.name}</h3>
          <p className="text-3xl font-bold">{plan.price}</p>
          <ul>
            {plan.features.map((f) => (
              <li key={f}>✓ {f}</li>
            ))}
          </ul>
          {plan.priceId ? (
            <CheckoutButton priceId={plan.priceId} />
          ) : (
            <button>Get Started Free</button>
          )}
        </div>
      ))}
    </div>
  );
}

See also: Best Boilerplates with Stripe Integration on StarterPick — save time with a StarterPick starter that pre-wires Stripe.

6. Managing Subscription State in Your App

Once Stripe is handling billing, your application needs to track subscription status to gate features. The recommended pattern is to store the relevant Stripe data in your own database and keep it updated via webhooks.

// Database schema (Prisma example)
model User {
  id                   String   @id @default(cuid())
  email                String   @unique
  stripeCustomerId     String?  @unique  // cus_xxx
  subscriptionId       String?           // sub_xxx
  subscriptionStatus   String?           // 'active', 'past_due', 'canceled', etc.
  subscriptionPriceId  String?           // price_xxx (which plan)
  currentPeriodEnd     DateTime?         // When the current billing period ends
}

Update these fields in your webhook handler whenever subscription status changes:

// In your webhook handler
case 'customer.subscription.updated': {
  const subscription = event.data.object as Stripe.Subscription;
  await db.user.update({
    where: { stripeCustomerId: subscription.customer as string },
    data: {
      subscriptionId: subscription.id,
      subscriptionStatus: subscription.status,
      subscriptionPriceId: subscription.items.data[0]?.price.id ?? null,
      currentPeriodEnd: new Date(subscription.current_period_end * 1000),
    },
  });
  break;
}

Gate features by checking subscriptionStatus === 'active' in your middleware or server components — never by calling the Stripe API on each request, as that's slow and burns API rate limits.

7. Testing Your Integration

Before switching to live keys, test every payment scenario:

# Stripe CLI test card numbers (no real charges)
# Success:           4242 4242 4242 4242
# Decline:           4000 0000 0000 0002
# 3D Secure:         4000 0025 0000 3155
# Insufficient funds: 4000 0000 0000 9995

# Trigger specific webhook events for testing
stripe trigger payment_intent.succeeded
stripe trigger invoice.payment_failed
stripe trigger customer.subscription.deleted

Test each scenario in your webhook handler:

  1. Successful one-time payment → order fulfilled in DB
  2. Successful subscription → user upgraded in DB
  3. Failed subscription payment → past_due status, access retained for retry period
  4. Subscription cancellation → access revoked at current_period_end
  5. Subscription reactivation → access reinstated

For CI/CD, Stripe provides a stripe mock server that can run without network access, though the Stripe CLI with stripe listen is more practical for local integration tests.

Production Checklist

ItemStatusNotes
Switch to live API keysRequiredsk_live_ and pk_live_
Register production webhook endpointRequiredStripe Dashboard → Webhooks
Handle idempotencyRequiredUse event.id to deduplicate webhook events
Store customer IDs in databaseRequiredLink Stripe customers to your users
Error handling on all API callsRequiredWrap in try/catch, log failures
Retry logic for webhook failuresRecommendedStripe retries automatically for 3 days
Tax collection (Stripe Tax)DependsRequired for many jurisdictions
Invoice generationRecommendedAutomatic with subscriptions
Refund handlingRecommendedHandle charge.refunded webhook
PCI complianceAutomaticStripe Checkout handles PCI for you

International Payments and Tax

Stripe supports 135+ currencies and automatic tax collection in most jurisdictions. For international products, a few practical notes:

Currency: Specify the currency in your Price object. If you want to charge in multiple currencies (e.g., USD for US customers, EUR for European customers), create a Price per currency per Product. Stripe's Adaptive Pricing can automatically localize amounts for international customers.

Tax: Stripe Tax collects and remits sales tax, VAT, and GST automatically based on the customer's location. Enable it in the Stripe Dashboard (Tax → Activate) and add automatic_tax: { enabled: true } to your Checkout Session. Stripe calculates the applicable tax rate at checkout time based on the billing address.

Strong Customer Authentication (SCA): European payments under PSD2 require two-factor authentication. Stripe Checkout handles SCA automatically — you don't need to implement 3D Secure yourself. If you're building a custom payment form with Stripe Elements instead of Checkout, you need to handle the requires_action state in the PaymentIntent flow.

Handling Webhook Idempotency

Stripe may deliver the same webhook event more than once — network retries are inherent in the system. Your webhook handler must be idempotent: processing the same event twice should not double-fulfill an order or double-upgrade a user.

The simplest approach is to store processed event IDs in your database:

case 'checkout.session.completed': {
  const session = event.data.object as Stripe.Checkout.Session;

  // Idempotency check: skip if already processed
  const existing = await db.processedEvents.findUnique({ where: { eventId: event.id } });
  if (existing) {
    return NextResponse.json({ received: true }); // Already handled
  }

  // Fulfill the order
  await handleCheckoutComplete(session);

  // Mark event as processed
  await db.processedEvents.create({ data: { eventId: event.id, processedAt: new Date() } });
  break;
}

An alternative is to make the fulfillment operation itself idempotent — using an upsert instead of an insert for database writes, for example. Either approach works; explicit event deduplication is more reliable when multiple events can trigger the same state change.

Common Mistakes

MistakeImpactFix
Not verifying webhook signaturesSecurity vulnerabilityAlways use constructEvent()
Using req.json() instead of req.text() for webhooksSignature verification failsParse raw body first, then verify
Fulfilling orders on success pageUnreliable — user may close browserUse webhooks for fulfillment
Exposing secret key in client codeAccount compromiseKeep sk_ on server only
Not handling subscription failuresUsers get free access indefinitelyHandle invoice.payment_failed
Not deduplicating webhook eventsDouble-fulfillment bugsCheck event ID before processing
Storing subscription status client-sideUsers can fake premium accessAlways gate access from server/DB

Testing Your Stripe Integration

Stripe's test mode is one of its best features, and it mirrors production behavior exactly — including webhook events, subscription lifecycle, dunning, and 3D Secure flows. All test mode credentials start with sk_test_ (secret key) and pk_test_ (publishable key). No real charges are made, and test transactions are completely isolated from live data.

Stripe provides a set of test card numbers that trigger specific behaviors. The most important ones: 4242 4242 4242 4242 completes a successful payment with any future expiry and any CVC. 4000 0025 0000 3155 triggers a 3D Secure authentication challenge — essential to test if your integration handles the requires_action state correctly. 4000 0000 0000 9995 results in a declined payment due to insufficient funds, which you should use to verify your failure UI and error handling. Always use expiry dates in the future and any 3-digit CVC; the actual values don't matter in test mode.

For local webhook testing, the Stripe CLI is the right tool. Run stripe listen --forward-to localhost:3000/api/webhooks/stripe to create a temporary tunnel that forwards Stripe webhook events to your local server. The CLI prints each incoming event with its type and data, which makes debugging webhook handler logic much faster than deploying to a staging environment. The CLI also provides a webhook signing secret that you use as your STRIPE_WEBHOOK_SECRET for local testing — different from your production webhook secret.

To test specific events without triggering the full payment flow, use stripe trigger payment_intent.succeeded or stripe trigger invoice.payment_failed. These inject realistic synthetic events into your local webhook handler, which is useful for testing edge cases like subscription cancellation or payment failure flows that are awkward to trigger manually.

Stripe's test clock feature is the most underused testing tool in the ecosystem. Test clocks let you fast-forward time to test subscription lifecycle events that would otherwise require waiting days or months. Create a test clock, attach a customer and subscription to it, then advance the clock by 30 days to trigger a renewal event — or advance past the trial end date to test trial conversion. This is the only reliable way to verify that your invoice.payment_succeeded handler correctly extends access on renewal before shipping to production.

Handling Subscription Lifecycle Events

The initial checkout is the easiest part of a Stripe subscription integration. The complexity lives in the ongoing lifecycle: plan changes, renewals, failed payments, cancellations, and reactivations. Every subscription app must handle these events reliably — gaps here lead to users retaining access after cancellation or losing access after a successful payment.

The key webhook events for subscription management are: customer.subscription.created fires when a new subscription is established. customer.subscription.updated fires on any change — plan upgrades, downgrades, quantity changes, or status transitions (from trialing to active, from active to past_due). This event is your primary mechanism for keeping your database in sync with Stripe. customer.subscription.deleted fires when a subscription actually ends — importantly, this fires at the end of the billing period when the subscription expires, not when the customer clicks "cancel." If a customer cancels mid-period, Stripe sets cancel_at_period_end: true and the subscription continues until period end; customer.subscription.deleted fires when it actually terminates. invoice.payment_failed triggers the dunning flow — Stripe will retry automatically, but your app should notify the user and potentially gate access after a grace period. invoice.payment_succeeded fires on both initial payment and every renewal; use it to confirm access extension.

The recommended pattern is to handle all subscription state changes through a single syncSubscriptionToDb function that you call from every relevant event handler:

// Webhook handler pattern for subscription sync
const relevantEvents = new Set([
  'customer.subscription.created',
  'customer.subscription.updated',
  'customer.subscription.deleted',
  'invoice.payment_succeeded',
  'invoice.payment_failed',
]);

// In your webhook handler:
if (relevantEvents.has(event.type)) {
  const subscription = event.data.object as Stripe.Subscription;
  await syncSubscriptionToDb(subscription);
}

The syncSubscriptionToDb function should upsert the subscription status, current period end, and price ID into your user record. This keeps your database authoritative and makes feature gating simple: check subscriptionStatus === 'active' from your own database rather than calling Stripe's API on every request. One critical detail: when handling invoice.payment_succeeded, the event's data.object is an Invoice, not a Subscription — retrieve the subscription via invoice.subscription before syncing.

Common Stripe Mistakes

Even experienced developers hit the same set of Stripe integration mistakes. Here are the most consequential ones, and what to do instead:

MistakeImpactFix
Not verifying webhook signaturesAny server can send fake payment events — a critical security holeAlways use stripe.webhooks.constructEvent() with your webhook secret
Reading subscription status from DB without cross-checkingDB can be stale if webhooks were missed or delayedTrust your DB for routine checks, but re-verify via Stripe API before granting irreversible access
Not handling invoice.payment_failedUsers retain full access indefinitely when their payment failsImplement dunning logic: notify on first failure, gate after grace period
Hardcoding price IDs in source codeSwitching plans or environments requires code changesStore price IDs in environment variables (NEXT_PUBLIC_STRIPE_PRO_PRICE_ID)
Not enabling Stripe RadarFraud goes undetected until chargebacks arriveEnable Radar in the Dashboard — it's free and blocks most card testing attacks automatically
Fulfilling orders in the success redirectUsers who close the browser before redirect don't get accessAlways fulfill via webhooks; use the success page only for UX confirmation
Long-lived checkout sessions without expirySessions accumulate; some payment methods have side effects if sessions expire naturallySet expires_at on Checkout Sessions for time-sensitive offers

The most dangerous mistake on this list is not handling invoice.payment_failed. Without this handler, a user whose card declines continues to have active subscription status in your database. Stripe retries for up to 7 days by default, but if all retries fail, the subscription moves to past_due and eventually canceled — but only if your webhook handler updates your database when those events fire. The entire dunning flow is only as good as your event handling.


Building with Stripe? Explore payment API comparisons and integration guides on APIScout — Stripe vs Square, Stripe vs Paddle, and more.

Related: Build a Payment System: Stripe + Plaid 2026, Building a SaaS Backend, Subscription Billing with Lemon Squeezy 2026

The API Integration Checklist (Free PDF)

Step-by-step checklist: auth setup, rate limit handling, error codes, SDK evaluation, and pricing comparison for 50+ APIs. Used by 200+ developers.

Join 200+ developers. Unsubscribe in one click.