You need to let users pay with Stripe in your Next.js app, but you're not sure how to create the checkout flow, keep your DB in sync, and give customers a self-service portal.
Step 1: Create the Stripe account and products
- Sign up at https://stripe.com and stay in test mode.
- Products > Add product
- Name: "Pro Plan"
- Pricing: Recurring, $29/month (add an annual price at $290/year for the discount)
- Save and copy the price ID (
price_xxx)
Repeat for each tier (Starter, Pro, Enterprise). Grab two keys from the dashboard:
-
STRIPE_PUBLISHABLE_KEY(pk_test_...in test mode) -
STRIPE_SECRET_KEY(sk_test_...)
You'll also need a webhook signing secret later.
Step 2: Install the SDK
npm install stripe @stripe/stripe-js Add the keys to .env.local:
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_... STRIPE_SECRET_KEY=sk_test_... STRIPE_WEBHOOK_SECRET=whsec_... NEXT_PUBLIC_SITE_URL=http://localhost:3000 Create a Stripe client wrapper at lib/stripe.ts:
import Stripe from 'stripe'; export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2025-10-29.acacia', typescript: true, }); Step 3: Build the Checkout Session
Create a server action to start checkout:
'use server'; import { stripe } from '@/lib/stripe'; import { auth } from '@clerk/nextjs/server'; import { redirect } from 'next/navigation'; export async function createCheckoutSession(priceId: string) { const { userId } = await auth(); if (!userId) throw new Error('Unauthorized'); const session = await stripe.checkout.sessions.create({ mode: 'subscription', line_items: [{ price: priceId, quantity: 1 }], success_url: `${process.env.NEXT_PUBLIC_SITE_URL}/billing/success?session_id={CHECKOUT_SESSION_ID}`, cancel_url: `${process.env.NEXT_PUBLIC_SITE_URL}/billing`, client_reference_id: userId, metadata: { userId }, subscription_data: { metadata: { userId } }, allow_promotion_codes: true, automatic_tax: { enabled: true }, }); if (session.url) redirect(session.url); } Key points:
-
client_reference_idlinks the session to your user. -
metadataon the session and subscription gives redundant traceability. -
allow_promotion_codesenables discount codes. -
automatic_taxturns on Stripe Tax (requires setup).
Step 4: Trigger Checkout from the UI
'use client'; import { createCheckoutSession } from '@/app/actions/stripe'; export function CheckoutButton({ priceId, label }: { priceId: string; label: string }) { return ( <form action={async () => { await createCheckoutSession(priceId); }}> <button type="submit">{label}</button> </form> ); } When the button is clicked, the server action creates a session and redirects the user to Stripe-hosted checkout. After payment they return to /billing/success.
Step 5: Wire the Webhook
Stripe updates its own records; without a webhook your DB never knows a payment succeeded.
Create app/api/webhooks/stripe/route.ts:
import { headers } from 'next/headers'; import { stripe } from '@/lib/stripe'; import { db } from '@/lib/db'; import Stripe from 'stripe'; export async function POST(req: Request) { const body = await req.text(); const signature = (await headers()).get('stripe-signature'); if (!signature) { return new Response('Missing signature', { status: 400 }); } let event: Stripe.Event; try { event = stripe.webhooks.constructEvent( body, signature, process.env.STRIPE_WEBHOOK_SECRET! ); } catch (err) { return new Response(`Webhook signature failed: ${err}`, { status: 400 }); } switch (event.type) { case 'checkout.session.completed': { const session = event.data.object; const userId = session.client_reference_id || session.metadata?.userId; if (userId && session.subscription) { await db.subscription.upsert({ where: { userId }, create: { userId, stripeCustomerId: session.customer as string, stripeSubscriptionId: session.subscription as string, status: 'active', }, update: { stripeCustomerId: session.customer as string, stripeSubscriptionId: session.subscription as string, status: 'active', }, }); } break; } case 'customer.subscription.updated': { const sub = event.data.object; await db.subscription.update({ where: { stripeSubscriptionId: sub.id }, data: { status: sub.status, currentPeriodEnd: new Date(sub.current_period_end * 1000), cancelAtPeriodEnd: sub.cancel_at_period_end, }, }); break; } case 'customer.subscription.deleted': { const sub = event.data.object; await db.subscription.update({ where: { stripeSubscriptionId: sub.id }, data: { status: 'canceled' }, }); break; } case 'invoice.payment_failed': { const invoice = event.data.object; // Notify the user, mark subscription as past_due, etc. break; } } return new Response('OK'); } In the Stripe dashboard, add a webhook endpoint pointing to https://your-app.com/api/webhooks/stripe and subscribe to:
checkout.session.completedcustomer.subscription.updatedcustomer.subscription.deletedinvoice.payment_failedinvoice.paid
Copy the webhook signing secret (whsec_...) into STRIPE_WEBHOOK_SECRET.
For local development, forward events with the Stripe CLI:
stripe listen --forward-to http://localhost:3000/api/webhooks/stripe The CLI prints a local whsec_... secret you can use during dev.
Step 6: Stripe Customer Portal
Instead of building your own subscription management UI, redirect users to Stripe's portal.
'use server'; import { stripe } from '@/lib/stripe'; import { auth } from '@clerk/nextjs/server'; import { db } from '@/lib/db'; import { redirect } from 'next/navigation'; export async function openCustomerPortal() { const { userId } = await auth(); if (!userId) throw new Error('Unauthorized'); const subscription = await db.subscription.findUnique({ where: { userId } }); if (!subscription) throw new Error('No subscription'); const session = await stripe.billingPortal.sessions.create({ customer: subscription.stripeCustomerId, return_url: `${process.env.NEXT_PUBLIC_SITE_URL}/billing`, }); redirect(session.url); } The portal lets users upgrade/downgrade plans, update payment methods, download invoices, and cancel. Enable the desired features in Stripe under Settings > Customer portal.
Step 7: Enable Stripe Tax (optional)
If you need to collect sales tax, enable Stripe Tax in the dashboard (Settings > Tax). Register the jurisdictions where you have nexus and assign tax codes to your prices. With automatic_tax: { enabled: true } in the checkout session, Stripe will calculate and collect tax automatically.
You can skip this step for a US-only, pre-revenue product and add it later when you cross a state's economic nexus threshold.
Step 8: One-Time Payments (non-subscription)
For a single purchase, set mode: 'payment' instead of 'subscription':
const session = await stripe.checkout.sessions.create({ mode: 'payment', line_items: [{ price: priceId, quantity: 1 }], success_url: `${process.env.NEXT_PUBLIC_SITE_URL}/checkout/success`, cancel_url: `${process.env.NEXT_PUBLIC_SITE_URL}/checkout`, client_reference_id: userId, metadata: { userId, type: 'one_time' }, }); The same webhook (checkout.session.completed) fires; grant lifetime access in your DB rather than creating a subscription record.
Common Gotchas
- Skipping webhooks entirely. Users can pay, Stripe shows a successful charge, but your app never records it. Implement the webhook before you go live.
- Mismatched environment keys. Using a live publishable key with a test secret (or vice-versa) causes "Invalid API Key" errors. Keep test and production keys separate.
- Forgot to set
client_reference_id. Without it you can't reliably map a checkout session back to your user in the webhook. - Webhook URL not reachable. In production the endpoint must be publicly accessible and use HTTPS; otherwise Stripe will keep retrying and eventually disable the webhook.
- Tax settings not enabled but
automatic_taxset. Stripe will reject the session creation; either enable Stripe Tax or remove the flag.
Address these issues early and the Stripe integration will stay in sync with your Next.js app.
This is a shorter version of a guide I wrote on thefrontkit. The full post has the complete walkthrough.


