← Back to articles

How to Add Stripe Payments to Next.js (2026 Guide)

Adding payments to your Next.js app is simpler than you think. This guide covers everything from one-time payments to subscriptions using Stripe Checkout and webhooks.

Prerequisites

  • Next.js 14+ (App Router)
  • Stripe account (free to create)
  • Node.js 18+

Step 1: Install Dependencies

npm install stripe @stripe/stripe-js

Step 2: Set Up Environment Variables

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

Get these from your Stripe Dashboard.

Step 3: Create a Stripe Instance

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

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

Step 4: Create a Checkout Session API Route

One-Time Payment

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

export async function POST(req: NextRequest) {
  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: `${req.nextUrl.origin}/success?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${req.nextUrl.origin}/pricing`,
  })

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

Subscription

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

export async function POST(req: NextRequest) {
  const { priceId, userId } = await req.json()

  const session = await stripe.checkout.sessions.create({
    mode: 'subscription',
    payment_method_types: ['card'],
    line_items: [
      {
        price: priceId,
        quantity: 1,
      },
    ],
    metadata: {
      userId, // Link the Stripe customer to your user
    },
    success_url: `${req.nextUrl.origin}/dashboard?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${req.nextUrl.origin}/pricing`,
  })

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

Step 5: Create the Checkout Button

// components/CheckoutButton.tsx
'use client'

import { useState } from 'react'

export function CheckoutButton({ priceId }: { priceId: string }) {
  const [loading, setLoading] = useState(false)

  const handleCheckout = async () => {
    setLoading(true)
    try {
      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
    } catch (error) {
      console.error('Checkout error:', error)
    } finally {
      setLoading(false)
    }
  }

  return (
    <button onClick={handleCheckout} disabled={loading}>
      {loading ? 'Loading...' : 'Subscribe'}
    </button>
  )
}

Step 6: Handle Webhooks

Webhooks are how Stripe tells your app about payment events. This is the most important part.

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

export async function POST(req: NextRequest) {
  const body = await req.text()
  const signature = req.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 purchase
      // e.g., update user's subscription status in your database
      await handleCheckoutComplete(session)
      break
    }

    case 'customer.subscription.updated': {
      const subscription = event.data.object as Stripe.Subscription
      // Handle plan changes, cancellations, etc.
      await handleSubscriptionUpdate(subscription)
      break
    }

    case 'customer.subscription.deleted': {
      const subscription = event.data.object as Stripe.Subscription
      // Handle subscription cancellation
      await handleSubscriptionCancelled(subscription)
      break
    }

    case 'invoice.payment_failed': {
      const invoice = event.data.object as Stripe.Invoice
      // Handle failed payment (notify user, retry, etc.)
      await handlePaymentFailed(invoice)
      break
    }
  }

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

async function handleCheckoutComplete(session: Stripe.Checkout.Session) {
  const userId = session.metadata?.userId
  const subscriptionId = session.subscription as string

  // Update your database
  await db.user.update({
    where: { id: userId },
    data: {
      stripeCustomerId: session.customer as string,
      stripeSubscriptionId: subscriptionId,
      plan: 'pro', // or derive from the price
    },
  })
}

async function handleSubscriptionUpdate(subscription: Stripe.Subscription) {
  // Update subscription status in your database
  await db.user.update({
    where: { stripeSubscriptionId: subscription.id },
    data: {
      plan: subscription.status === 'active' ? 'pro' : 'free',
    },
  })
}

async function handleSubscriptionCancelled(subscription: Stripe.Subscription) {
  await db.user.update({
    where: { stripeSubscriptionId: subscription.id },
    data: { plan: 'free' },
  })
}

async function handlePaymentFailed(invoice: Stripe.Invoice) {
  // Send email to user about failed payment
  // Stripe handles retries automatically
}

Step 7: 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

# Copy the webhook signing secret it outputs to your .env.local

Step 8: Create a Customer Portal (Subscriptions)

Let users manage their own subscriptions:

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

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

  const session = await stripe.billingPortal.sessions.create({
    customer: customerId,
    return_url: `${req.nextUrl.origin}/dashboard`,
  })

  return NextResponse.json({ url: session.url })
}
// components/ManageSubscriptionButton.tsx
'use client'

export function ManageSubscriptionButton({ customerId }: { customerId: string }) {
  const handleManage = async () => {
    const res = await fetch('/api/portal', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ customerId }),
    })
    const { url } = await res.json()
    window.location.href = url
  }

  return <button onClick={handleManage}>Manage Subscription</button>
}

Step 9: Protect Routes Based on Subscription

// lib/subscription.ts
export async function getUserSubscription(userId: string) {
  const user = await db.user.findUnique({ where: { id: userId } })
  return user?.plan ?? 'free'
}

// middleware.ts (or in your layout/page)
export async function middleware(request: NextRequest) {
  const session = await getSession()
  if (!session) return NextResponse.redirect('/login')
  
  const plan = await getUserSubscription(session.userId)
  if (plan === 'free' && request.nextUrl.pathname.startsWith('/pro')) {
    return NextResponse.redirect('/pricing')
  }
}

Best Practices

Security

  • Always verify webhook signatures. Never trust unverified webhook payloads.
  • Never expose your secret key. Only NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY goes to the client.
  • Validate on the server. Don't trust client-side price IDs without server validation.
  • Use idempotency keys for critical operations to prevent double charges.

User Experience

  • Use Stripe Checkout instead of custom forms. It handles card validation, 3D Secure, and PCI compliance.
  • Show loading states during checkout redirect.
  • Handle the success page properly — verify the session before showing confirmation.
  • Provide a customer portal so users can manage their own subscriptions.

Architecture

  • Webhooks are the source of truth. Don't rely on the redirect back from Checkout — the user might close the tab. Webhooks guarantee you know about every payment.
  • Store Stripe customer ID in your database. Link it to your user at first checkout.
  • Handle edge cases: failed payments, disputed charges, subscription downgrades.

Common Stripe Products for SaaS

Pricing Page Setup

Create products and prices in Stripe Dashboard or via API:

// One-time setup (run once)
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 ($24.17/mo — 2 months free)
  currency: 'usd',
  recurring: { interval: 'year' },
})

Test Cards

Use these in test mode:

  • Success: 4242 4242 4242 4242
  • Requires auth: 4000 0025 0000 3155
  • Declined: 4000 0000 0000 0002

Any future expiry date and any 3-digit CVC.

FAQ

Do I need PCI compliance?

If you use Stripe Checkout (recommended), Stripe handles PCI compliance. You never touch card numbers. If you build a custom payment form with Stripe Elements, you need SAQ A-EP (still simple, Stripe guides you).

How do I handle taxes?

Enable Stripe Tax in your dashboard. Add automatic_tax: { enabled: true } to your checkout session. Stripe calculates and collects the right tax based on customer location.

What about refunds?

Handle via Stripe Dashboard or API: await stripe.refunds.create({ payment_intent: 'pi_...' }). Add a charge.refunded webhook handler to update your database.

Stripe vs Lemon Squeezy vs Paddle?

Stripe gives you the most control but you handle tax compliance. Lemon Squeezy and Paddle are Merchants of Record — they handle taxes globally but take higher fees and give less control. Read our comparison →

The Bottom Line

The minimal Stripe + Next.js integration:

  1. Create products/prices in Stripe Dashboard
  2. Checkout API route → redirects to Stripe Checkout
  3. Webhook handler → processes payment events
  4. Customer portal → lets users manage subscriptions
  5. Route protection → based on subscription status

Total code: ~200 lines. Time to implement: 2-4 hours. You're collecting payments.

Get AI tool guides in your inbox

Weekly deep-dives on the best AI coding tools, automation platforms, and productivity software.