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-jsCreate 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 webhooksStripe 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-fieldsStep 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.completedinvoice.payment_succeededinvoice.payment_failedcustomer.subscription.updatedcustomer.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/webhooksThe 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— succeeds4000 0000 0000 9995— payment fails (tests your failure webhook handler)4000 0025 0000 3155— requires 3D Secure authentication
Going to Production
- Switch
STRIPE_SECRET_KEYto your live key (sk_live_...) - Switch
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEYto live publishable key - Create a new webhook endpoint in Stripe pointing to your production URL
- Update
STRIPE_WEBHOOK_SECRETto the production signing secret - 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.