How to Add Stripe Payments to Your Next.js App
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
| Item | Status | Notes |
|---|---|---|
| Switch to live API keys | Required | sk_live_ and pk_live_ |
| Register production webhook endpoint | Required | Stripe Dashboard → Webhooks |
| Handle idempotency | Required | Use event.id to deduplicate webhook events |
| Store customer IDs in database | Required | Link Stripe customers to your users |
| Error handling on all API calls | Required | Wrap in try/catch, log failures |
| Retry logic for webhook failures | Recommended | Stripe retries automatically for 3 days |
| Tax collection (Stripe Tax) | Depends | Required for many jurisdictions |
| Invoice generation | Recommended | Automatic with subscriptions |
| Refund handling | Recommended | Handle charge.refunded webhook |
| PCI compliance | Automatic | Stripe Checkout handles PCI for you |
Common Mistakes
| Mistake | Impact | Fix |
|---|---|---|
| Not verifying webhook signatures | Security vulnerability | Always use constructEvent() |
Using req.json() instead of req.text() for webhooks | Signature verification fails | Parse raw body first, then verify |
| Fulfilling orders on success page | Unreliable — user may close browser | Use webhooks for fulfillment |
| Exposing secret key in client code | Account compromise | Keep sk_ on server only |
| Not handling subscription failures | Users get free access indefinitely | Handle invoice.payment_failed |
Building with Stripe? Explore payment API comparisons and integration guides on APIScout — Stripe vs Square, Stripe vs Paddle, and more.