How to Add Stripe Payments to Next.js (2026)
Stripe is the standard for SaaS payments. This guide covers everything: creating products, checkout sessions, handling webhooks, customer portal, and managing subscriptions in a Next.js app.
Setup
npm install stripe @stripe/stripe-js
# .env.local
STRIPE_SECRET_KEY=sk_test_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
Stripe Client
// src/lib/stripe.ts
import Stripe from 'stripe'
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-12-18.acacia',
})
Create Products in Stripe Dashboard
- Go to Stripe Dashboard → Products
- Create a product (e.g., "Pro Plan")
- Add prices:
- Monthly: $19/month (recurring)
- Yearly: $190/year (recurring)
- Copy the Price IDs (
price_xxx)
Checkout (Subscriptions)
API Route
// src/app/api/stripe/checkout/route.ts
import { auth } from '@clerk/nextjs/server'
import { stripe } from '@/lib/stripe'
export async function POST(req: Request) {
const { userId } = auth()
if (!userId) return new Response('Unauthorized', { status: 401 })
const { priceId } = await req.json()
// Get or create Stripe customer
const user = await db.query.users.findFirst({ where: eq(users.id, userId) })
let customerId = user?.stripeCustomerId
if (!customerId) {
const customer = await stripe.customers.create({
metadata: { userId },
email: user?.email,
})
customerId = customer.id
await db.update(users).set({ stripeCustomerId: customerId }).where(eq(users.id, userId))
}
const session = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'subscription',
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_URL}/dashboard?upgraded=true`,
cancel_url: `${process.env.NEXT_PUBLIC_URL}/pricing`,
subscription_data: {
trial_period_days: 14, // Optional: 14-day trial
},
})
return Response.json({ url: session.url })
}
Pricing Page Component
'use client'
export function PricingCard({ name, price, priceId, features, popular }) {
const handleCheckout = async () => {
const res = await fetch('/api/stripe/checkout', {
method: 'POST',
body: JSON.stringify({ priceId }),
})
const { url } = await res.json()
window.location.href = url
}
return (
<div className={`border rounded-lg p-6 ${popular ? 'border-blue-500 ring-2 ring-blue-500' : ''}`}>
<h3 className="text-xl font-bold">{name}</h3>
<p className="text-3xl font-bold mt-2">{price}</p>
<ul className="mt-4 space-y-2">
{features.map(f => <li key={f}>✓ {f}</li>)}
</ul>
{priceId && (
<button onClick={handleCheckout} className="mt-6 w-full bg-black text-white py-2 rounded">
Get Started
</button>
)}
</div>
)
}
Webhook Handler
This is the most important part. Stripe sends events to your webhook endpoint:
// src/app/api/webhooks/stripe/route.ts
import { stripe } from '@/lib/stripe'
import { headers } from 'next/headers'
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) {
return new Response('Webhook signature verification failed', { status: 400 })
}
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session
// Activate subscription
await db.update(users)
.set({ plan: 'pro' })
.where(eq(users.stripeCustomerId, session.customer as string))
break
}
case 'invoice.payment_succeeded': {
// Recurring payment succeeded — keep plan active
const invoice = event.data.object as Stripe.Invoice
await db.update(users)
.set({ plan: 'pro' })
.where(eq(users.stripeCustomerId, invoice.customer as string))
break
}
case 'invoice.payment_failed': {
// Payment failed — notify user, maybe downgrade after grace period
const invoice = event.data.object as Stripe.Invoice
// Send email: "Your payment failed, please update your card"
break
}
case 'customer.subscription.deleted': {
// Subscription cancelled
const sub = event.data.object as Stripe.Subscription
await db.update(users)
.set({ plan: 'free' })
.where(eq(users.stripeCustomerId, sub.customer as string))
break
}
}
return new Response('OK')
}
Customer Portal (Manage Subscriptions)
Let users manage their own billing — upgrade, downgrade, cancel, update payment method:
// src/app/api/stripe/portal/route.ts
import { auth } from '@clerk/nextjs/server'
import { stripe } from '@/lib/stripe'
export async function POST() {
const { userId } = auth()
if (!userId) return new Response('Unauthorized', { status: 401 })
const user = await db.query.users.findFirst({ where: eq(users.id, userId) })
if (!user?.stripeCustomerId) {
return new Response('No subscription', { status: 400 })
}
const session = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: `${process.env.NEXT_PUBLIC_URL}/dashboard/settings`,
})
return Response.json({ url: session.url })
}
One-Time Payments
For credits, lifetime deals, or one-time features:
const session = await stripe.checkout.sessions.create({
mode: 'payment', // Not 'subscription'
line_items: [{
price_data: {
currency: 'usd',
product_data: { name: '100 Credits' },
unit_amount: 999, // $9.99 in cents
},
quantity: 1,
}],
success_url: `${process.env.NEXT_PUBLIC_URL}/dashboard?purchased=credits`,
cancel_url: `${process.env.NEXT_PUBLIC_URL}/pricing`,
})
Testing Locally
# Install Stripe CLI
brew install stripe/stripe-cli/stripe
# Forward webhooks to your local server
stripe listen --forward-to localhost:3000/api/webhooks/stripe
Use test card numbers:
- Success:
4242 4242 4242 4242 - Decline:
4000 0000 0000 0002 - 3D Secure:
4000 0025 0000 3155
Common Patterns
Check Subscription Status
export async function getUserPlan(userId: string) {
const user = await db.query.users.findFirst({ where: eq(users.id, userId) })
return user?.plan || 'free'
}
// In a Server Component
const plan = await getUserPlan(userId)
if (plan === 'free') return <UpgradePrompt />
Metered Billing (Usage-Based)
// Report usage to Stripe
await stripe.subscriptionItems.createUsageRecord(subscriptionItemId, {
quantity: apiCallCount,
timestamp: Math.floor(Date.now() / 1000),
action: 'increment',
})
FAQ
Should I use Stripe Checkout or Stripe Elements?
Stripe Checkout (redirect to Stripe-hosted page) for 90% of SaaS apps. Stripe Elements (embedded payment form) when you need full design control.
How do I handle failed payments?
Listen for invoice.payment_failed webhook. Send the user an email. Stripe retries automatically (Smart Retries). Downgrade after 3 failed attempts.
Stripe vs Lemon Squeezy?
Stripe: more control, better API, you handle tax. Lemon Squeezy: handles tax, simpler setup, higher fees. Use Lemon Squeezy if you don't want to deal with sales tax.
Bottom Line
Stripe Checkout + webhooks + Customer Portal = complete billing in a few hours. Don't build custom payment forms. Use Stripe's hosted pages and focus on your product.