๐ŸŒ Detecting your locationโ€ฆ

How to Add Stripe Payments to a Next.js App in 2026: Complete Integration Guide

โฑ๏ธ7 min read  ยท  1,384 words

Most Stripe integrations fail in the same place: they treat the browser redirect as proof of payment. It is not. The only reliable source of truth is the webhook Stripe sends your server. This guide builds a correct integration in Next.js with the App Router โ€” Checkout, webhooks with signature verification, idempotent fulfilment, and subscriptions.

Architecture First

Understanding the flow prevents the two bugs that break most integrations.

  1. Your server creates a Checkout Session and returns its URL.
  2. The browser redirects to Stripe. The user pays on Stripe’s page โ€” card details never touch your server.
  3. Stripe redirects the browser back to your success URL.
  4. Separately, Stripe sends a webhook to your server confirming the payment.
  5. Your webhook handler grants access. Not the success page.

Step 5 is the whole game. The user can close the tab before the redirect, lose connectivity, or simply edit the success URL and visit it directly. Grant access only on the webhook.

Step 1: Install and Configure

npm install stripe @stripe/stripe-js

Store keys in environment variables. The secret key must never reach the client, so it gets no NEXT_PUBLIC_ prefix.

# .env.local
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
NEXT_PUBLIC_BASE_URL=http://localhost:3000

Create a single shared Stripe client so you are not constructing one per request.

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

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

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
  apiVersion: '2026-06-30',
  typescript: true,
});

Pinning apiVersion matters. Without it you inherit whatever version your account defaults to, and that can change under you.

Step 2: Create the Checkout Session

Never accept a price from the client. Send a product identifier and look the price up on the server, or the user simply edits the request and pays one cent.

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

const PRICES: Record<string, string> = {
  pro_monthly: 'price_1AbCdEfGhIjKlMnO',
  pro_yearly:  'price_1XyZaBcDeFgHiJkL',
};

export async function POST(req: Request) {
  const user = await getCurrentUser();
  if (!user) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  const { plan } = await req.json();
  const priceId = PRICES[plan];
  if (!priceId) {
    return NextResponse.json({ error: 'Unknown plan' }, { status: 400 });
  }

  const session = await stripe.checkout.sessions.create({
    mode: 'subscription',
    line_items: [{ price: priceId, quantity: 1 }],
    success_url: `${process.env.NEXT_PUBLIC_BASE_URL}/welcome?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url:  `${process.env.NEXT_PUBLIC_BASE_URL}/pricing`,
    customer_email: user.email,
    // client_reference_id survives the round trip and arrives in the webhook.
    client_reference_id: user.id,
    metadata: { userId: user.id, plan },
  });

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

client_reference_id and metadata are how you know which user paid when the webhook arrives. Omit them and you will be matching payments to accounts by email, which breaks the moment someone pays with a different address.

Step 3: Redirect from the Client

'use client';

export function UpgradeButton({ plan }: { plan: string }) {
  async function handleClick() {
    const res = await fetch('/api/checkout', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ plan }),
    });

    if (!res.ok) {
      alert('Could not start checkout. Please try again.');
      return;
    }

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

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

Step 4: The Webhook โ€” Where Correctness Lives

Two things break webhooks in Next.js. First, you must verify the signature against the raw request body; if anything parses it into JSON first, verification fails. Second, you must handle duplicate deliveries, because Stripe retries and will deliver the same event more than once.

// app/api/webhooks/stripe/route.ts
import { NextResponse } from 'next/server';
import { headers } from 'next/headers';
import { stripe } from '@/lib/stripe';
import { grantAccess, revokeAccess, hasProcessed, markProcessed } from '@/lib/billing';

export async function POST(req: Request) {
  // req.text() gives the raw body, which signature verification requires.
  const body = await req.text();
  const signature = (await headers()).get('stripe-signature');

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

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

  // Stripe retries on any non-2xx, so the same event can arrive repeatedly.
  if (await hasProcessed(event.id)) {
    return NextResponse.json({ received: true });
  }

  try {
    switch (event.type) {
      case 'checkout.session.completed': {
        const session = event.data.object;
        const userId = session.metadata?.userId ?? session.client_reference_id;
        if (userId) {
          await grantAccess(userId, {
            customerId: session.customer as string,
            subscriptionId: session.subscription as string,
          });
        }
        break;
      }

      case 'customer.subscription.deleted': {
        await revokeAccess(event.data.object.customer as string);
        break;
      }

      case 'invoice.payment_failed': {
        // Notify the user; do not revoke immediately โ€” cards fail temporarily.
        break;
      }
    }

    await markProcessed(event.id);
    return NextResponse.json({ received: true });
  } catch (err) {
    console.error('Webhook handler failed:', err);
    // Return 500 so Stripe retries rather than dropping the event.
    return NextResponse.json({ error: 'Handler failed' }, { status: 500 });
  }
}

hasProcessed and markProcessed should write to a table keyed on the Stripe event ID with a unique constraint. That single constraint is what makes fulfilment idempotent โ€” without it, a retry double-credits the account.

Step 5: Test Webhooks Locally

Stripe cannot reach localhost, so forward events with the CLI.

stripe login
stripe listen --forward-to localhost:3000/api/webhooks/stripe

# In another terminal, fire a test event:
stripe trigger checkout.session.completed

stripe listen prints a webhook signing secret. Use that value for STRIPE_WEBHOOK_SECRET in development โ€” it differs from the one in the Dashboard.

Step 6: The Success Page Confirms, It Does Not Grant

The success page should read state your webhook already wrote. If the webhook has not landed yet, show a pending state rather than granting anything.

// app/welcome/page.tsx
import { getCurrentUser } from '@/lib/auth';
import { getSubscription } from '@/lib/billing';

export default async function WelcomePage() {
  const user = await getCurrentUser();
  const subscription = await getSubscription(user.id);

  if (!subscription?.active) {
    return (
      <p>Payment received. Your account is being activated โ€” this usually takes a few seconds.</p>
    );
  }

  return <h1>Welcome to Pro</h1>;
}

Step 7: Let Customers Manage Their Own Subscriptions

The Billing Portal handles plan changes, cancellations, invoices, and card updates. Building those flows yourself is weeks of work you do not need to do.

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

export async function POST() {
  const user = await getCurrentUser();
  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });

  const customerId = await getCustomerId(user.id);
  if (!customerId) return NextResponse.json({ error: 'No subscription' }, { status: 400 });

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

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

Going Live Checklist

  • Swap test keys for live keys, and create a separate webhook endpoint in live mode with its own signing secret
  • Confirm your webhook URL is publicly reachable and returns 2xx quickly โ€” do slow work asynchronously
  • Enable Stripe Tax if you sell across jurisdictions
  • Turn on Radar rules for fraud protection
  • Test the full flow with a real card, then refund it
  • Verify your idempotency table has a unique index on the event ID

Common Mistakes

Granting access on the success page. Users who never reach it still paid, and users who visit it directly never did.

Parsing the body before signature verification. Verification needs the raw bytes. Use req.text() and nothing else first.

Trusting a price from the client. Look prices up server-side by identifier, always.

Returning 200 on handler failure. Stripe treats 2xx as success and stops retrying, so the event is lost permanently. Return 500 and let it retry.

Revoking access on the first failed payment. Cards fail for temporary reasons. Follow Stripe’s dunning process instead.

Conclusion

A correct Stripe integration rests on a few rules: create sessions server-side with prices you control, treat the webhook as the only source of truth, verify signatures against the raw body, make fulfilment idempotent with a unique constraint on the event ID, and return non-2xx when handling fails so Stripe retries. Hand subscription management to the Billing Portal rather than rebuilding it. Get these right and payments become the least eventful part of your application.

MD Rafikul Islam

Written by

MD Rafikul Islam is a software developer and the editor of TechPulse. He writes about developer tooling, hardware, and the practical decisions that come up in day-to-day engineering work โ€” which laptop to buy, which framework to commit to, why a build broke at 2am. He tests the tools he writes about and says plainly when something is not worth the money. Corrections and corrections requests are welcome at rony.yf25@gmail.com.

โœ๏ธ Leave a Comment

Your email address will not be published. Required fields are marked *