Skip to main content

How to Add Stripe Payments to Your Next.js App

·APIScout Team
stripenextjspaymentstutorialapi integration

How to Add Stripe Payments to Your Next.js App

Stripe is the most popular payment API for developers. This guide walks you through a complete integration: one-time payments with Checkout Sessions, recurring subscriptions, webhook handling, and the production checklist you need before going live.

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).

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

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.

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>
  );
}

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

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

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

Comments