Tutorials

Stripe Payments in Next.js 15 (App Router) — Complete Tutorial 2026

Add Stripe payments to a Next.js 15 app from scratch. Checkout, webhooks, subscriptions, customer portal, and middleware for access control.

April 27, 202613 min read
Share:
Stripe Payments in Next.js 15 (App Router) — Complete Tutorial 2026

Every tutorial for Stripe in Next.js is either outdated (Pages Router), incomplete (stops at Checkout), or skips the hard part (webhooks). This one covers the full stack for a working payment system: Checkout, webhooks updating your database, subscriptions, and a customer portal — all in the Next.js 15 App Router.

What You'll Build

  • Stripe Checkout for one-time and subscription payments
  • Webhooks to update your database when payments succeed or fail
  • Customer portal for subscription management (cancel, upgrade, update card)
  • Middleware to protect premium routes based on subscription status
  • Test mode setup you can switch to production with one environment variable

Setup

npm install stripe @stripe/stripe-js

Create a Stripe account at stripe.com and get your keys from the dashboard.

# .env.local
STRIPE_SECRET_KEY=sk_test_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...  # get this after setting up webhooks

Stripe Client Setup

// lib/stripe.ts
 
import Stripe from 'stripe'
 
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2024-06-20',
  typescript: true,
})

Prisma Schema for Subscriptions

You need to store Stripe data for each user to know their subscription status:

// prisma/schema.prisma (add to existing User model)
 
model User {
  id                     String    @id @default(cuid())
  email                  String    @unique
  name                   String?
  stripeCustomerId       String?   @unique
  stripeSubscriptionId   String?   @unique
  stripePriceId          String?
  stripeCurrentPeriodEnd DateTime?
  // ... rest of your fields
}

Run the migration:

npx prisma migrate dev --name add-stripe-fields

Step 1: Create Products and Prices in Stripe

Do this in the Stripe Dashboard (Products section) or via the API:

// scripts/seed-stripe.ts (run once)
 
import { stripe } from '@/lib/stripe'
 
async function main() {
  const product = await stripe.products.create({
    name: 'Pro Plan',
    description: 'Access to all premium features',
  })
 
  const monthlyPrice = await stripe.prices.create({
    product: product.id,
    unit_amount: 1900, // $19.00 in cents
    currency: 'usd',
    recurring: { interval: 'month' },
  })
 
  const yearlyPrice = await stripe.prices.create({
    product: product.id,
    unit_amount: 19000, // $190.00/year
    currency: 'usd',
    recurring: { interval: 'year' },
  })
 
  console.log('Monthly price ID:', monthlyPrice.id)
  console.log('Yearly price ID:', yearlyPrice.id)
}
 
main()

Add the price IDs to your environment:

STRIPE_PRO_MONTHLY_PRICE_ID=price_...
STRIPE_PRO_YEARLY_PRICE_ID=price_...

Step 2: Create Checkout Session API Route

// app/api/stripe/checkout/route.ts
 
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { stripe } from '@/lib/stripe'
import { prisma } from '@/lib/prisma'
import { z } from 'zod'
 
const checkoutSchema = z.object({
  priceId: z.string(),
})
 
export async function POST(request: NextRequest) {
  try {
    const session = await auth()
 
    if (!session?.user?.id) {
      return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
    }
 
    const body = await request.json()
    const { priceId } = checkoutSchema.parse(body)
 
    const user = await prisma.user.findUnique({
      where: { id: session.user.id },
    })
 
    if (!user) {
      return NextResponse.json({ error: 'User not found' }, { status: 404 })
    }
 
    // Create or retrieve Stripe customer
    let stripeCustomerId = user.stripeCustomerId
 
    if (!stripeCustomerId) {
      const customer = await stripe.customers.create({
        email: user.email!,
        name: user.name ?? undefined,
        metadata: { userId: user.id },
      })
 
      stripeCustomerId = customer.id
 
      await prisma.user.update({
        where: { id: user.id },
        data: { stripeCustomerId },
      })
    }
 
    const baseUrl = process.env.NEXT_PUBLIC_APP_URL!
 
    const checkoutSession = await stripe.checkout.sessions.create({
      customer: stripeCustomerId,
      mode: 'subscription',
      payment_method_types: ['card'],
      line_items: [{ price: priceId, quantity: 1 }],
      success_url: `${baseUrl}/dashboard?payment=success`,
      cancel_url: `${baseUrl}/pricing?payment=cancelled`,
      metadata: { userId: user.id },
      subscription_data: {
        metadata: { userId: user.id },
      },
      allow_promotion_codes: true,
    })
 
    return NextResponse.json({ url: checkoutSession.url })
  } catch (error) {
    if (error instanceof z.ZodError) {
      return NextResponse.json({ error: error.errors }, { status: 400 })
    }
    console.error('Checkout error:', error)
    return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
  }
}

Step 3: Pricing Page with Checkout Button

// app/pricing/page.tsx
 
import { PricingCard } from '@/components/PricingCard'
 
const plans = [
  {
    name: 'Free',
    price: 0,
    priceId: null,
    features: ['5 projects', 'Basic analytics', 'Email support'],
  },
  {
    name: 'Pro Monthly',
    price: 19,
    priceId: process.env.STRIPE_PRO_MONTHLY_PRICE_ID!,
    features: ['Unlimited projects', 'Advanced analytics', 'Priority support', 'API access'],
    popular: true,
  },
  {
    name: 'Pro Yearly',
    price: 190,
    priceId: process.env.STRIPE_PRO_YEARLY_PRICE_ID!,
    features: ['Everything in Pro', '2 months free', 'Custom integrations'],
    badge: 'Best Value',
  },
]
 
export default function PricingPage() {
  return (
    <div className="max-w-5xl mx-auto py-20 px-4">
      <h1 className="text-4xl font-bold text-center mb-4">Simple pricing</h1>
      <p className="text-center text-gray-500 mb-12">
        Start free. Upgrade when you need more.
      </p>
      <div className="grid grid-cols-1 md:grid-cols-3 gap-8">
        {plans.map((plan) => (
          <PricingCard key={plan.name} plan={plan} />
        ))}
      </div>
    </div>
  )
}
// components/PricingCard.tsx
 
'use client'
 
import { useState } from 'react'
import { useRouter } from 'next/navigation'
 
interface Plan {
  name: string
  price: number
  priceId: string | null
  features: string[]
  popular?: boolean
  badge?: string
}
 
export function PricingCard({ plan }: { plan: Plan }) {
  const router = useRouter()
  const [loading, setLoading] = useState(false)
 
  async function handleCheckout() {
    if (!plan.priceId) return
 
    setLoading(true)
 
    try {
      const response = await fetch('/api/stripe/checkout', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ priceId: plan.priceId }),
      })
 
      const data = await response.json()
 
      if (data.url) {
        window.location.href = data.url
      }
    } catch (error) {
      console.error('Checkout failed:', error)
    } finally {
      setLoading(false)
    }
  }
 
  return (
    <div className={`relative rounded-2xl border p-8 ${
      plan.popular ? 'border-black shadow-lg' : 'border-gray-200'
    }`}>
      {plan.badge && (
        <span className="absolute -top-3 left-1/2 -translate-x-1/2 bg-black text-white text-xs px-3 py-1 rounded-full">
          {plan.badge}
        </span>
      )}
 
      <h3 className="text-lg font-semibold mb-2">{plan.name}</h3>
      <div className="mb-6">
        <span className="text-4xl font-bold">${plan.price}</span>
        {plan.price > 0 && (
          <span className="text-gray-500">/year</span>
        )}
      </div>
 
      <ul className="space-y-3 mb-8">
        {plan.features.map((feature) => (
          <li key={feature} className="flex items-center gap-2 text-sm">
            <span className="text-green-500">✓</span>
            {feature}
          </li>
        ))}
      </ul>
 
      {plan.priceId ? (
        <button
          onClick={handleCheckout}
          disabled={loading}
          className="w-full bg-black text-white py-2.5 rounded-lg hover:bg-gray-800 disabled:opacity-50 transition"
        >
          {loading ? 'Loading...' : 'Get started'}
        </button>
      ) : (
        <button className="w-full border border-gray-200 py-2.5 rounded-lg hover:bg-gray-50 transition">
          Current plan
        </button>
      )}
    </div>
  )
}

Step 4: Webhooks (The Critical Part)

Webhooks are how Stripe tells your server that a payment succeeded, failed, or a subscription changed. This is the part most tutorials skip, and it's the most important.

Set Up the Webhook Endpoint

In the Stripe Dashboard: Developers → Webhooks → Add endpoint

  • URL: https://your-domain.com/api/stripe/webhooks
  • Events to listen for:
    • checkout.session.completed
    • invoice.payment_succeeded
    • invoice.payment_failed
    • customer.subscription.updated
    • customer.subscription.deleted

Copy the Signing Secret (whsec_...) to your .env.local.

Webhook Handler

// app/api/stripe/webhooks/route.ts
 
import { NextRequest, NextResponse } from 'next/server'
import Stripe from 'stripe'
import { stripe } from '@/lib/stripe'
import { prisma } from '@/lib/prisma'
 
// IMPORTANT: disable body parsing for webhook signature verification
export const runtime = 'nodejs'
 
async function updateUserSubscription(
  userId: string,
  subscription: Stripe.Subscription
) {
  await prisma.user.update({
    where: { id: userId },
    data: {
      stripeSubscriptionId: subscription.id,
      stripePriceId: subscription.items.data[0].price.id,
      stripeCurrentPeriodEnd: new Date(subscription.current_period_end * 1000),
    },
  })
}
 
export async function POST(request: NextRequest) {
  const body = await request.text()
  const signature = request.headers.get('stripe-signature')!
 
  let event: Stripe.Event
 
  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    )
  } catch (error) {
    console.error('Webhook signature verification failed:', error)
    return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
  }
 
  try {
    switch (event.type) {
      case 'checkout.session.completed': {
        const session = event.data.object as Stripe.CheckoutSession
 
        if (session.mode === 'subscription') {
          const subscription = await stripe.subscriptions.retrieve(
            session.subscription as string
          )
          const userId = subscription.metadata.userId
 
          if (userId) {
            await updateUserSubscription(userId, subscription)
          }
        }
        break
      }
 
      case 'invoice.payment_succeeded': {
        const invoice = event.data.object as Stripe.Invoice
 
        if (invoice.subscription) {
          const subscription = await stripe.subscriptions.retrieve(
            invoice.subscription as string
          )
          const userId = subscription.metadata.userId
 
          if (userId) {
            await updateUserSubscription(userId, subscription)
          }
        }
        break
      }
 
      case 'invoice.payment_failed': {
        const invoice = event.data.object as Stripe.Invoice
 
        if (invoice.subscription) {
          const subscription = await stripe.subscriptions.retrieve(
            invoice.subscription as string
          )
          const userId = subscription.metadata.userId
 
          if (userId) {
            // Mark subscription as past_due — restrict access
            await prisma.user.update({
              where: { id: userId },
              data: {
                stripeCurrentPeriodEnd: new Date(0), // past date = no access
              },
            })
          }
        }
        break
      }
 
      case 'customer.subscription.updated': {
        const subscription = event.data.object as Stripe.Subscription
        const userId = subscription.metadata.userId
 
        if (userId) {
          await updateUserSubscription(userId, subscription)
        }
        break
      }
 
      case 'customer.subscription.deleted': {
        const subscription = event.data.object as Stripe.Subscription
        const userId = subscription.metadata.userId
 
        if (userId) {
          await prisma.user.update({
            where: { id: userId },
            data: {
              stripeSubscriptionId: null,
              stripePriceId: null,
              stripeCurrentPeriodEnd: null,
            },
          })
        }
        break
      }
    }
 
    return NextResponse.json({ received: true })
  } catch (error) {
    console.error('Webhook handler error:', error)
    return NextResponse.json(
      { error: 'Webhook handler failed' },
      { status: 500 }
    )
  }
}

Step 5: Customer Portal

Let users manage their subscription (cancel, upgrade, update payment method) without you building any UI:

// app/api/stripe/portal/route.ts
 
import { NextResponse } from 'next/server'
import { auth } from '@/auth'
import { stripe } from '@/lib/stripe'
import { prisma } from '@/lib/prisma'
 
export async function POST() {
  const session = await auth()
 
  if (!session?.user?.id) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }
 
  const user = await prisma.user.findUnique({
    where: { id: session.user.id },
  })
 
  if (!user?.stripeCustomerId) {
    return NextResponse.json({ error: 'No subscription found' }, { status: 404 })
  }
 
  const portalSession = await stripe.billingPortal.sessions.create({
    customer: user.stripeCustomerId,
    return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard`,
  })
 
  return NextResponse.json({ url: portalSession.url })
}

Enable the customer portal in Stripe Dashboard → Settings → Billing → Customer portal. Configure what customers can do (cancel, switch plans, update card).

Add a button anywhere in your dashboard:

// components/ManageSubscriptionButton.tsx
 
'use client'
 
export function ManageSubscriptionButton() {
  async function handleManage() {
    const res = await fetch('/api/stripe/portal', { method: 'POST' })
    const data = await res.json()
    if (data.url) window.location.href = data.url
  }
 
  return (
    <button onClick={handleManage} className="text-sm underline text-gray-600">
      Manage subscription
    </button>
  )
}

Step 6: Subscription Utility Function

Add a helper to check if a user has an active subscription:

// lib/subscription.ts
 
import { prisma } from '@/lib/prisma'
 
export async function getUserSubscription(userId: string) {
  const user = await prisma.user.findUnique({
    where: { id: userId },
    select: {
      stripeSubscriptionId: true,
      stripePriceId: true,
      stripeCurrentPeriodEnd: true,
    },
  })
 
  const isActive =
    user?.stripeCurrentPeriodEnd !== null &&
    user?.stripeCurrentPeriodEnd !== undefined &&
    new Date(user.stripeCurrentPeriodEnd) > new Date()
 
  return {
    isActive,
    subscriptionId: user?.stripeSubscriptionId,
    priceId: user?.stripePriceId,
    currentPeriodEnd: user?.stripeCurrentPeriodEnd,
  }
}

Step 7: Protect Premium Routes

Use middleware to block unauthenticated or unpaid users:

// middleware.ts
 
import { auth } from '@/auth'
import { NextResponse } from 'next/server'
 
export default auth(async (request) => {
  const { nextUrl, auth: session } = request
  const isLoggedIn = !!session
 
  const isPremiumRoute = nextUrl.pathname.startsWith('/dashboard/pro') ||
                         nextUrl.pathname.startsWith('/api/premium')
 
  if (isPremiumRoute) {
    if (!isLoggedIn) {
      return NextResponse.redirect(new URL('/auth/login', nextUrl))
    }
 
    // Check subscription in the database
    const user = await prisma.user.findUnique({
      where: { id: session.user.id },
      select: { stripeCurrentPeriodEnd: true },
    })
 
    const hasActiveSubscription =
      user?.stripeCurrentPeriodEnd &&
      new Date(user.stripeCurrentPeriodEnd) > new Date()
 
    if (!hasActiveSubscription) {
      return NextResponse.redirect(new URL('/pricing', nextUrl))
    }
  }
 
  return NextResponse.next()
})

Step 8: Test Everything Locally

Use the Stripe CLI to forward webhooks to your local machine:

# Install Stripe CLI
brew install stripe/stripe-cli/stripe
 
# Log in
stripe login
 
# Forward webhooks to your local Next.js server
stripe listen --forward-to localhost:3000/api/stripe/webhooks

The CLI outputs a webhook secret for local testing — use this as STRIPE_WEBHOOK_SECRET in your .env.local.

Test a payment with Stripe's test cards:

  • 4242 4242 4242 4242 — succeeds
  • 4000 0000 0000 9995 — payment fails (tests your failure webhook handler)
  • 4000 0025 0000 3155 — requires 3D Secure authentication

Going to Production

  1. Switch STRIPE_SECRET_KEY to your live key (sk_live_...)
  2. Switch NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY to live publishable key
  3. Create a new webhook endpoint in Stripe pointing to your production URL
  4. Update STRIPE_WEBHOOK_SECRET to the production signing secret
  5. Enable the Customer Portal in production settings

Two environment variable changes. Everything else stays the same.

Common Mistakes

Not verifying webhook signatures. Anyone can hit your webhook endpoint with fake data. Always use stripe.webhooks.constructEvent() — it throws if the signature is invalid.

Trusting the checkout session for access. The success URL is not a reliable signal. Always rely on webhooks to update your database, never on redirect parameters.

Not handling invoice.payment_failed. Subscription renewals can fail. If you don't handle this, users get permanent access even after their card fails. The webhook is your cutoff mechanism.

Fetching subscription status on every request. Cache the stripeCurrentPeriodEnd in your database and check it locally — don't call the Stripe API in middleware, it's too slow.

For building the complete Next.js app that this fits into, see the Next.js full-stack TypeScript tutorial. For adding authentication (required before payments), see the Next.js authentication guide. For deploying to production, the Next.js performance optimization guide covers Vercel and other hosting options.

Stripe's test mode is identical to production — once this works locally, shipping to production is two variable swaps.

#nextjs#stripe#payments#tutorial#saas
Share:

Enjoyed this article?

Join 2,400+ developers getting weekly insights on Claude Code, React, and AI tools.

No spam. Unsubscribe anytime. By subscribing you agree to our Privacy Policy.