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

  1. Sign up at https://stripe.com and stay in test mode.
  2. Products > Add product
  3. Name: "Pro Plan"
  4. Pricing: Recurring, $29/month (add an annual price at $290/year for the discount)
  5. 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_id links the session to your user.
  • metadata on the session and subscription gives redundant traceability.
  • allow_promotion_codes enables discount codes.
  • automatic_tax turns 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.completed
  • customer.subscription.updated
  • customer.subscription.deleted
  • invoice.payment_failed
  • invoice.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_tax set. 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.