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.
Top comments (0)