Payments & Stripe
Accept payments, sell subscriptions, and get paid. The boring-but-bulletproof recipe: Stripe Checkout for the payment page, the Customer Portal for self-service, and verified webhooks as the source of truth.
Why Stripe?
Stripe handles the scary stuff: PCI compliance, fraud detection, global payment methods, tax, and invoicing. You focus on your product. With hosted Checkout, card numbers never touch your server.
PCI Handled
You never touch card data
Global
Many currencies and payment methods
Subscriptions
Built-in recurring billing
Invoices & Tax
Stripe Tax can calculate it for you
Pricing: you mostly pay per successful transaction, and the rate varies by country and payment method. Check stripe.com/pricing for your region before you set your prices.
Pick Your Integration
One-Time Payments
Customer pays once, gets the thing. Digital downloads, lifetime deals, e-commerce.
Use: Checkout with mode: "payment"
Subscriptions
Recurring billing. Monthly or yearly plans for SaaS, memberships, newsletters.
Use: Checkout with mode: "subscription" + Customer Portal
Checkout (recommended)
Stripe-hosted payment page. Least code, handles wallets, 3D Secure, and tax.
Payment Links
No code at all. Create a link in the Dashboard. Great for validating demand.
Elements
Embed payment fields in your own UI. More control, more code. Graduate here later.
Quick Setup: Stripe Checkout in Next.js
Stripe hosts the payment page; your server creates a Checkout Session and redirects the user to it.
Create an account, a sandbox, and a product
Sign up at stripe.com. Work in a sandbox (Stripe's isolated test environment). Create a product with a price in the Dashboard and copy its Price ID ( API Key A unique code that identifies you when using an API. It's how services know who's making requests (and who to bill). "Like a VIP pass. It proves you're allowed in and tracks your usage."price_...). Grab your API keys
Add keys to .env.local
.env.local
STRIPE_SECRET_KEY=sk_test_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_... # from "stripe listen" (see Testing below)
STRIPE_PRICE_PRO=price_...Install and create a server-only client
Terminal
npm install stripe server-onlylib/stripe.ts
import "server-only";
import Stripe from "stripe";
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);Create the Checkout Session route
app/api/checkout/route.ts
import { NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";
export async function POST(req: Request) {
const origin = req.headers.get("origin") ?? "http://localhost:3000";
const session = await stripe.checkout.sessions.create({
mode: "payment", // or "subscription"
line_items: [{ price: process.env.STRIPE_PRICE_PRO!, quantity: 1 }],
// Tip: pass customer_email or client_reference_id (your user ID)
// so the webhook knows who paid.
success_url: `${origin}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${origin}/pricing`,
});
return NextResponse.redirect(session.url!, 303);
}No payment_method_types needed: Checkout shows cards and other methods you enable in the Dashboard automatically.
Add a checkout button
A plain form works in a Server Component Server Component A React component that runs ONLY on the server. Can directly access databases, fetch data, and keeps secrets safe. The default in Next.js App Router. "Like the kitchen in a restaurant. Customers never see it, but that's where the magic happens."
app/pricing/page.tsx
export default function Pricing() {
return (
<form action="/api/checkout" method="POST">
<button type="submit">Upgrade to Pro</button>
</form>
);
}Webhooks: The Critical Part
Don't skip this!
Users can close the tab before reaching your success page. A webhook Webhook An automatic message sent from one app to another when something happens. Like 'Hey, a user signed up!' in real-time. "Like a doorbell. Instead of constantly checking, you get notified when someone arrives."
In a Next.js route handler, read the raw body with await req.text() and verify the signature before trusting anything:
app/api/webhooks/stripe/route.ts
import type Stripe from "stripe";
import { stripe } from "@/lib/stripe";
export async function POST(req: Request) {
const body = await req.text(); // raw body: required for verification
const sig = req.headers.get("stripe-signature");
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, sig!, process.env.STRIPE_WEBHOOK_SECRET!);
} catch {
return new Response("Invalid signature", { status: 400 });
}
if (event.type === "checkout.session.completed") {
const session = event.data.object;
// Grant access / fulfill the order. Make this idempotent:
// Stripe can deliver the same event more than once.
}
return new Response("OK", { status: 200 });
}Retries, duplicate events, event ordering, and a full idempotent handler are in the Webhooks guide.
Subscriptions in Three Moves
Create a recurring price
Dashboard → Product catalog → add a product with a recurring (monthly or yearly) price.
Use mode: "subscription" in Checkout
Same route as above, recurring Price ID. Checkout creates the Customer and Subscription for you.
Turn on the Customer Portal
Configure it under Dashboard settings → Billing → Customer portal. Users cancel, switch plans, and update cards without you building any UI.
Common Pitfalls
Fulfilling on the success page
Relying on the success_url redirect means missed orders when users close the tab, and free stuff for anyone who guesses the URL.
Fix: Fulfill in a verified webhook. The success page is just for a nice "thanks!" message.
Trusting the price from the browser
If your checkout route reads the amount from the request body, anyone can pay $0.01 for your Pro plan.
Fix: Keep prices on the server: look up a Stripe Price ID by plan name, never accept an amount from the client.
Sandbox vs. live mode confusion
Test keys in production = no real money. Live keys in dev = real charges on your own card.
Fix: Use sk_test_ keys in .env.local and Preview env vars; sk_live_ only in Vercel's Production env vars. Each has its own webhook signing secret too.
Double charges on retries
A network blip makes your code retry a create call, and Stripe creates two of the thing.
Fix: Pass an idempotency key on POST requests you might retry: stripe.customers.create(params, { idempotencyKey }). Stripe returns the first result for repeats of the same key.
Not handling failed payments
Cards expire. If you don't react, users keep access they stopped paying for.
Fix: Listen for invoice.payment_failed and subscription status changes. See the subscriptions guide.
Exposing the secret key
An sk_ key in client-side code means anyone can issue refunds, read customers, or create charges on your account.
Fix: Secret key stays on the server (add import "server-only" to the file that uses it). Only the publishable key may use the NEXT_PUBLIC_ prefix.
Testing Payments
In a sandbox, use these test cards (any future expiry, any 3-digit CVC):
4242 4242 4242 4242Success4000 0000 0000 9995Declined (insufficient funds)4000 0025 0000 3155Requires 3D Secure authenticationTest webhooks locally with the Stripe CLI:
Terminal
# Install the Stripe CLI (other options: docs.stripe.com/cli/install)
npm install -g @stripe/cli
stripe login
# Forward events to your local route; copy the whsec_... it prints
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# In another terminal, fire a test event
stripe trigger checkout.session.completedYou're ready to monetize!
Lock down the webhook, then add recurring plans.