Payments & Stripe
Accept payments, sell subscriptions, and monetize your app. Stripe is the industry standard - here's how to use it.
Why Stripe?
Stripe handles the scary stuff: PCI compliance, fraud detection, global payment methods, tax calculation, invoicing. You focus on your product.
PCI Compliant
You never touch card data
135+ Currencies
Sell globally
Subscriptions
Built-in recurring billing
Invoices & Tax
Automatic tax calculation
Pricing: 2.9% + 30¢ per transaction. No monthly fees. You only pay when you make money.
Types of Payments
One-Time Payments
Customer pays once, gets the thing. E-commerce, digital downloads, lifetime deals.
Use: Stripe Checkout or Payment Intents
Subscriptions
Recurring billing. Monthly/yearly plans. SaaS, memberships, newsletters.
Use: Stripe Billing + Customer Portal
Quick Setup: Stripe Checkout
Stripe Checkout is the fastest way to accept payments. Stripe hosts the payment page - you just redirect users to it.
Create Stripe account & get keys
Go to stripe.com, sign up, go to Developers → API Keys.
Add keys to .env.local
STRIPE_SECRET_KEY=sk_test_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
Install Stripe
npm install stripe
Create checkout session API route
// app/api/checkout/route.ts
import Stripe from 'stripe'
import { NextResponse } from 'next/server'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function POST() {
const session = await stripe.checkout.sessions.create({
mode: 'payment', // or 'subscription'
payment_method_types: ['card'],
line_items: [{
price_data: {
currency: 'usd',
product_data: { name: 'Pro Plan' },
unit_amount: 2900, // $29.00 in cents
},
quantity: 1,
}],
success_url: 'https://yoursite.com/success',
cancel_url: 'https://yoursite.com/pricing',
})
return NextResponse.json({ url: session.url })
}
Add checkout button
// components/BuyButton.tsx
async function handleCheckout() {
const res = await fetch('/api/checkout', { method: 'POST' })
const { url } = await res.json()
window.location.href = url // Redirect to Stripe
}
Webhooks: The Critical Part
Don't skip this!
Users can close the browser before hitting your success page. Webhooks are how Stripe tells your server "payment succeeded" reliably.
Webhooks are HTTP requests Stripe sends to your server when events happen (payment succeeded, subscription cancelled, etc.).
// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe'
import { headers } from 'next/headers'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function POST(req: Request) {
const body = await req.text()
const sig = headers().get('stripe-signature')!
const event = stripe.webhooks.constructEvent(
body, sig, process.env.STRIPE_WEBHOOK_SECRET!
)
if (event.type === 'checkout.session.completed') {
const session = event.data.object
// Grant access, send email, update DB, etc.
}
return new Response('OK', { status: 200 })
}
Key events to handle:
checkout.session.completed- Payment succeededcustomer.subscription.updated- Plan changedcustomer.subscription.deleted- Cancelledinvoice.payment_failed- Card declined
Subscriptions
For subscriptions, create Products and Prices in the Stripe Dashboard, then use their IDs.
Create products in Stripe Dashboard
Products → Add product → Add a recurring price (e.g., $29/month)
Use price ID in checkout
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
line_items: [{
price: 'price_1234567890', // From dashboard
quantity: 1,
}],
// ...
})
Enable Customer Portal
Let users manage their subscription (cancel, upgrade, update payment). Enable in Stripe Dashboard → Settings → Customer Portal.
Common Pitfalls
"Not using webhooks"
Relying only on the success_url redirect means missed payments if users close browser.
Fix: Always use webhooks to confirm payment. The redirect is just for UX.
"Test mode vs Live mode confusion"
Using test keys in production = no real money. Using live keys in dev = real charges!
Fix: Use sk_test_ locally, sk_live_ in production Vercel env vars.
"Hardcoding prices"
Changing prices requires code changes and redeployment.
Fix: Create prices in Stripe Dashboard, reference by price ID. Change prices without code.
"Not handling failed payments"
Card expires, insufficient funds - if you don't handle it, users keep access.
Fix: Listen for invoice.payment_failed webhook. Email user, retry, or revoke access.
"Exposing secret key"
Putting sk_ key in client-side code = anyone can charge your customers.
Fix: Secret key stays on server only. Only NEXT_PUBLIC_ prefix goes to client (publishable key only).
Testing Payments
Use these test card numbers (any future expiry, any CVC):
4242 4242 4242 4242Success4000 0000 0000 9995Declined (insufficient funds)4000 0000 0000 3220Requires 3D SecureTest webhooks locally:
# Install Stripe CLI
brew install stripe/stripe-cli/stripe
# Forward webhooks to localhost
stripe listen --forward-to localhost:3000/api/webhooks/stripe