Stripe is standard for SaaS subscriptions. It's powerful and safe, but the setup has edges. This guide walks through the essentials and gotchas.
Part 1: Create a Stripe Account and Get Your Keys
Go to stripe.com and sign up. You'll get two API keys: Publishable key (safe to expose in frontend code) and Secret key (never expose—keep in .env only). Stripe also gives you a webhook signing secret (used to verify that webhook events from Stripe are legitimate). Store these in your .env.local file: ``` NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_... STRIPE_SECRET_KEY=sk_live_... STRIPE_WEBHOOK_SECRET=whsec_... ``` The NEXT_PUBLIC_ prefix means it's safe for the browser (publishable key only). STRIPE_SECRET_KEY is server-only (never sent to client).
Part 2: Create Your Products and Prices in Stripe
In Stripe Dashboard, create a product ("Pro Plan") and add a price ($99/month or $990/year). You'll get a Price ID (price_1A2b3c4d...). This ID goes in your database and your code. Best practice: Store the Price ID in your database alongside your plan definition: ``` const PLANS = { starter: { price: '$29/month', stripePrice: 'price_123...' }, pro: { price: '$99/month', stripePrice: 'price_456...' }, }; ``` Never hardcode Stripe IDs in your UI. Always reference them from a secure source (database, config).
Part 3: Create a Subscription Session
When a user clicks "Subscribe," you create a Stripe Checkout session. This session is a URL you redirect the user to. They fill in their payment details on Stripe (secure) and return to your site. Server-side code (Next.js API route): ```typescript import Stripe from 'stripe'; const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); export default async function handler(req, res) { const session = await stripe.checkout.sessions.create({ customer_email: req.user.email, line_items: [ { price: 'price_456...', // Pro plan quantity: 1, }, ], mode: 'subscription', success_url: 'https://yoursite.com/success', cancel_url: 'https://yoursite.com/cancel', }); res.json({ sessionId: session.id }); } ``` Frontend (React): ```typescript const handleSubscribe = async () => { const res = await fetch('/api/create-checkout-session', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ planId: 'pro' }), }); const { sessionId } = await res.json(); const stripe = await loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY); stripe.redirectToCheckout({ sessionId }); }; ```
Part 4: Handle Webhook Events
When a user completes checkout, Stripe sends you a webhook event (checkout.session.completed). This event triggers your server to: (1) Verify the subscription was successful. (2) Create a subscription record in your database. (3) Activate the user's account. Without webhooks, you might miss subscription starts (user completes checkout, but your server crashes before saving). Webhooks are the reliable way. Create a webhook endpoint (e.g., /api/webhooks/stripe) in your Next.js API: ```typescript 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 = (await headers()).get('stripe-signature'); let event: Stripe.Event; try { event = stripe.webhooks.constructEvent( body, sig!, process.env.STRIPE_WEBHOOK_SECRET! ); } catch (error) { return Response.json({ error: 'Invalid signature' }, { status: 400 }); } if (event.type === 'checkout.session.completed') { const session = event.data.object as Stripe.Checkout.Session; // Save subscription to database await db.subscription.create({ userId: session.client_reference_id, stripeSubscriptionId: session.subscription, status: 'active', }); } return Response.json({ received: true }); } ``` In Stripe Dashboard, register your webhook endpoint. Stripe will send events to /api/webhooks/stripe.
Part 5: Handle Failures and Retries
Users will fail to pay for reasons: Invalid card, expired card, insufficient funds. Stripe handles retries automatically (retries 3 times over 5 days). But you need to handle the failed payment event. Listen for invoice.payment_failed and send the user an email: "Your payment failed. Update your payment method here: [link]." Always give users a way to update their payment method (Stripe billing portal or Stripe Customer Portal). Stripe recurring billing flow: Payment fails → Stripe retries automatically → After 3 failures, Stripe marks subscription as past_due → You send email reminder → If still unpaid after 7 days, you can cancel the subscription and disable their account.
Part 6: Testing Before Launch
Use Stripe test mode (keys start with pk_test_ and sk_test_). In test mode, you can subscribe without a real credit card. Test card numbers (from Stripe docs): - Successful: 4242 4242 4242 4242 (any future expiry, any CVC) - Decline: 4000 0000 0000 0002 (declines with insufficient funds error) - Authenticate: 4000 0000 0000 3220 (requires 3D Secure authentication) Test the full flow: Subscribe → Payment succeeds → Database updates → Check dashboard that subscription is created → Test customer portal. When ready for production: Switch to live keys (pk_live_, sk_live_). Live mode charges real credit cards.
Common Mistakes
Mistake 1: Exposing secret key in frontend code. Never. Store in .env.local (server only). Mistake 2: Not validating webhook signatures. Webhook could be forged (though unlikely). Always call stripe.webhooks.constructEvent() to verify. Mistake 3: Relying only on client-side success confirmation. User completes checkout, your JavaScript says "success," but webhook fails. Always validate via webhook. Mistake 4: Not handling payment failures. User's card declines, you don't send a reminder, subscription goes dark. Handle failed_payment events. Mistake 5: Hardcoding Price IDs in code. Store in database so you can change pricing without deploying.