EmpireUI
Get Pro
← Blog9 min read#stripe#react#payment

Stripe Integration in React + Next.js: Checkout, Webhooks, Subscriptions

Ship real payments in Next.js: Stripe Checkout, webhook verification, and subscription billing with the App Router — no fluff, just working code.

Code editor showing payment integration code for a React app

Why Stripe in Next.js Is Both Easy and Easy to Mess Up

Stripe's docs are genuinely good. That's the nice thing. The trap is that their examples still assume the Pages Router in a lot of places — and if you're on Next.js 14 or 15 with the App Router, you'll hit weird edge cases around route handlers, server actions, and middleware that the official guides gloss over.

Honestly, the biggest pain point isn't Stripe itself — it's where you put things. A Checkout Session belongs on the server. Your webhook handler needs the raw request body, not the parsed JSON that Next.js hands you by default. Get those two things wrong and you'll spend four hours debugging a 400 from Stripe.

This guide assumes you're on Next.js 14+ with the App Router and TypeScript. We're covering three things in sequence: hosted Checkout, webhook verification, and recurring subscriptions. Each section builds on the last, so read it in order the first time.

Setting Up: Keys, Packages, and Environment

Start with the Stripe dashboard. You need two key pairs — test and live. For now, just grab the test ones: STRIPE_SECRET_KEY (starts with sk_test_) and NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY (starts with pk_test_). Never mix these up in env files. Never commit them.

Install what you actually need — nothing more: ``bash npm install stripe @stripe/stripe-js ` stripe is the Node.js server SDK. @stripe/stripe-js is the browser-side package you'll use to redirect to Checkout or mount Elements. That's it. You don't need @stripe/react-stripe-js` unless you're embedding the card form directly — and for most SaaS apps, hosted Checkout is the right call anyway.

Create a singleton for the server-side Stripe instance. Don't initialize it in every route handler: ``ts // lib/stripe.ts import Stripe from 'stripe'; export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2024-06-20', typescript: true, }); ` Pin the API version. Stripe deploys API changes and if you leave apiVersion` unset, your types drift the moment they push an update.

Worth noting: the NEXT_PUBLIC_ prefix matters. Only values with that prefix get bundled into the client JS. Your secret key never gets that prefix — if it does, you've got a security problem.

Add these to .env.local for local dev, and add the same keys to your production environment (Vercel, Dokploy, wherever). The webhook secret — STRIPE_WEBHOOK_SECRET — comes later after you set up your endpoint.

Stripe Checkout: Creating a Session from a Route Handler

Hosted Checkout is the fastest path to money in the door. Stripe hosts the payment page, handles 3D Secure, and takes care of PCI compliance. You create a session on your server, redirect the user, and they come back to your success_url. That's the whole flow.

Here's a working route handler for creating a Checkout Session: ``ts // app/api/checkout/route.ts import { NextRequest, NextResponse } from 'next/server'; import { stripe } from '@/lib/stripe'; export async function POST(req: NextRequest) { const { priceId, userId } = await req.json(); const session = await stripe.checkout.sessions.create({ mode: 'payment', // or 'subscription' payment_method_types: ['card'], line_items: [ { price: priceId, quantity: 1, }, ], success_url: ${process.env.NEXT_PUBLIC_APP_URL}/success?session_id={CHECKOUT_SESSION_ID}, cancel_url: ${process.env.NEXT_PUBLIC_APP_URL}/pricing, metadata: { userId }, // pass your internal user ID through }); return NextResponse.json({ url: session.url }); } ` The metadata` field is your lifeline. Pass your internal user ID — whatever identifies the user in your database — because you'll need it in the webhook to fulfill the order.

On the client, hitting this endpoint and redirecting is straightforward: ``tsx // components/CheckoutButton.tsx 'use client'; import { useState } from 'react'; export function CheckoutButton({ priceId, userId }: { priceId: string; userId: string }) { const [loading, setLoading] = useState(false); const handleCheckout = async () => { setLoading(true); const res = await fetch('/api/checkout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ priceId, userId }), }); const { url } = await res.json(); window.location.href = url; }; return ( <button onClick={handleCheckout} disabled={loading}> {loading ? 'Redirecting...' : 'Buy now'} </button> ); } `` In practice, you'd style that button properly — if you want a head start on the UI side, browse components that already have polished button styles you can drop in.

One more thing — the success_url includes {CHECKOUT_SESSION_ID} as a literal template variable. Stripe replaces it with the actual session ID when it redirects the user back. You can then verify the session server-side on your success page, which is a lot safer than trusting a URL parameter blindly.

Quick aside: don't redirect to /success and immediately provision access based on the URL param alone. Verify the session with stripe.checkout.sessions.retrieve(sessionId) and check that payment_status === 'paid' before doing anything in your database.

Webhooks: The Part Everyone Gets Wrong

Webhooks are how Stripe tells your server what actually happened. Payment confirmed, subscription renewed, payment failed — all of it comes through webhooks. And if you don't handle them, your fulfillment logic is broken. You can't rely on the success redirect alone because users close tabs, networks drop, and the redirect doesn't guarantee the payment went through.

The critical thing with Next.js 14+ App Router: you need the raw request body for Stripe's signature verification. Next.js parses request bodies by default, which breaks stripe.webhooks.constructEvent(). You have to read the raw buffer: ``ts // app/api/webhooks/stripe/route.ts import { NextRequest, NextResponse } from 'next/server'; import { stripe } from '@/lib/stripe'; import Stripe from 'stripe'; export async function POST(req: NextRequest) { const body = await req.text(); // raw body as string const sig = req.headers.get('stripe-signature')!; let event: Stripe.Event; try { event = stripe.webhooks.constructEvent( body, sig, process.env.STRIPE_WEBHOOK_SECRET! ); } catch (err) { console.error('Webhook signature verification failed:', err); return NextResponse.json({ error: 'Invalid signature' }, { status: 400 }); } // Handle events switch (event.type) { case 'checkout.session.completed': { const session = event.data.object as Stripe.CheckoutSession; const userId = session.metadata?.userId; // provision access in your DB here break; } case 'customer.subscription.deleted': { const subscription = event.data.object as Stripe.Subscription; // revoke access break; } default: console.log(Unhandled event: ${event.type}); } return NextResponse.json({ received: true }); } `` Always return a 200. If you throw an error, Stripe retries for up to 72 hours. Return 200 even for events you don't handle.

For local testing, install the Stripe CLI and run: ``bash stripe listen --forward-to localhost:3000/api/webhooks/stripe ` This prints a webhook secret that's different from your production one. Store it as STRIPE_WEBHOOK_SECRET in .env.local`. When you deploy to production, you register the endpoint in the Stripe dashboard and get a different permanent secret.

Honestly, the most common webhook bug I see is people checking event.type === 'checkout.session.completed' and assuming the payment_intent is paid. Check session.payment_status too. For subscription flows specifically, also listen to invoice.payment_succeeded — that fires on every renewal.

Look, idempotency matters here. Stripe can and will send the same webhook more than once. Store the event.id and check if you've already processed it before touching your database. A simple check against a processed_webhook_events table saves you from double-provisioning nightmare scenarios.

Subscriptions: Plans, Portals, and Cancellations

Subscriptions in Stripe are just Checkout Sessions with mode: 'subscription' plus a price that has a recurring interval. Create your products and prices in the Stripe dashboard first — or via the API if you're building something programmatic. You get back a price ID like price_1ABC123... that you hardcode in your app or store in environment variables.

Switch the route handler from earlier to subscription mode: ``ts const session = await stripe.checkout.sessions.create({ mode: 'subscription', payment_method_types: ['card'], line_items: [{ price: priceId, quantity: 1 }], subscription_data: { metadata: { userId }, // also attach metadata to the subscription trial_period_days: 14, // optional free trial }, success_url: ${process.env.NEXT_PUBLIC_APP_URL}/dashboard?success=true, cancel_url: ${process.env.NEXT_PUBLIC_APP_URL}/pricing, metadata: { userId }, }); ` Note trial_period_days: 14` — that's how you add a free trial in 2024. No credit card charge upfront, but Stripe collects the card and bills automatically when the trial ends.

For managing subscriptions after signup — plan changes, cancellations, updating payment methods — don't build that UI yourself. Stripe's Customer Portal handles all of it. Create a portal session: ``ts // app/api/portal/route.ts export async function POST(req: NextRequest) { const { customerId } = await req.json(); const portalSession = await stripe.billingPortal.sessions.create({ customer: customerId, return_url: ${process.env.NEXT_PUBLIC_APP_URL}/dashboard, }); return NextResponse.json({ url: portalSession.url }); } ` You need to store the Stripe customer.id in your own database when the first subscription is created — grab it from the checkout.session.completed webhook event via session.customer`.

Worth noting: configure the portal in the Stripe dashboard under Billing → Customer portal. You can control which features are enabled — whether customers can cancel, switch plans, update cards, etc. Without that configuration step, the portal won't work even if the API call succeeds.

That said, if you want to build a custom subscription management UI rather than using the portal, look into Next.js server actions — they pair cleanly with Stripe API calls and let you update subscriptions from form submits without a separate API route. The pattern is solid, especially for things like one-click plan downgrades.

Handling the UI: Success Pages, Loading States, and Errors

The success page is where most tutorials stop, but it's actually where your UX can fall apart. You've redirected back from Stripe with ?session_id=cs_... in the URL. Now what? Verify it server-side in a Server Component: ``tsx // app/success/page.tsx import { stripe } from '@/lib/stripe'; export default async function SuccessPage({ searchParams, }: { searchParams: { session_id: string }; }) { const session = await stripe.checkout.sessions.retrieve( searchParams.session_id, { expand: ['payment_intent', 'customer'] } ); if (session.payment_status !== 'paid') { return <p>Payment not completed. Contact support.</p>; } return ( <div> <h1>You're in.</h1> <p>Receipt sent to {session.customer_details?.email}</p> </div> ); } `` This runs server-side — no flash of wrong content, no client-side fetch, no loading spinner needed.

For the checkout button itself, disabled states and optimistic feedback matter more than people think. Nobody wants to click a button and see nothing happen for three seconds while the session creates. Show a spinner immediately on click. If you're building out the full UI, Empire UI has animated button components that handle loading states with built-in accessibility — saves you writing the ARIA live regions yourself.

Error handling from Stripe is typed nicely in the SDK. Catch Stripe.errors.StripeCardError for card declines and return human-readable messages. For everything else, log the error and show a generic retry message — don't expose internal error strings to users.

Quick aside: if you're using the App Router and want to trigger Checkout from a server action instead of a client-side fetch, you can — but you'll need redirect() from next/navigation on the server side rather than window.location.href. The pattern works well for form-based flows where you're already submitting data before payment.

Testing, Going Live, and What to Double-Check

Stripe's test card numbers are well-documented: 4242 4242 4242 4242 for a successful payment, 4000 0000 0000 0002 for a card declined, 4000 0025 0000 3155 to trigger 3D Secure. Use these with any future expiry date (like 12/28) and any 3-digit CVC. Don't use real card numbers in test mode — they'll be rejected and flagged.

Before going live, run through this list. First: switch all sk_test_ and pk_test_ keys to live equivalents in production env vars. Second: set up a production webhook endpoint in the Stripe dashboard pointing at your deployed URL — https://yourdomain.com/api/webhooks/stripe — and grab the new live webhook secret. Third: test with a real card at small amounts. Fourth: make sure your webhook handler is idempotent (stores processed event IDs). Fifth: check that your Customer Portal configuration is enabled in the Stripe dashboard for live mode — it's separate from test mode settings.

In practice, the most common production bug is forgetting that test-mode webhook secrets don't work in production. You'll get 400s on every webhook and wonder why subscriptions aren't provisioning. It's always the webhook secret.

For the UI polish side — especially if you're building a pricing table to sit in front of Checkout — think carefully about what happens at 320px. Stripe's hosted Checkout page is mobile-optimized, but your own pricing UI needs to be too. A pricing card that breaks at narrow viewports loses real money. If you want inspiration, the glassmorphism components on Empire UI include a pricing card variant that scales cleanly down to mobile.

One more thing — audit your Stripe dashboard's radar rules before launch. Stripe Radar runs fraud detection by default, but you can tune it. If you're selling digital goods, add a rule to block certain high-risk countries or require CVC match. The dashboard's 'Radar for Fraud Teams' section walks you through it, and it's free on the standard plan up to a point.

FAQ

Do I need @stripe/react-stripe-js for hosted Checkout?

No. You only need @stripe/stripe-js (for loadStripe) if you're redirecting to Stripe's hosted page. @stripe/react-stripe-js is for embedding the card form directly in your own UI with Stripe Elements.

Why does stripe.webhooks.constructEvent() throw in Next.js App Router?

Next.js parses the request body before you read it, which corrupts the raw bytes Stripe needs for signature verification. Use await req.text() instead of await req.json() in your webhook route handler.

How do I store which plan a user is on?

Save the Stripe customer ID and subscription ID to your own database when you handle the checkout.session.completed webhook. Query subscription status via stripe.subscriptions.retrieve() when you need to check access — don't rely only on your DB cache.

Can I use server actions instead of API routes for Stripe?

Yes for creating sessions, but webhooks must stay as route handlers — Stripe calls them directly via HTTP, not through your app's form submissions. Use redirect() from next/navigation in the server action after creating the session URL.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Next.js App Router in 2026: What's Changed and What Still Trips People UpNext.js Server Actions in 2026: Forms, Mutations and the Right PatternsCheckout Form in React: Address, Payment, Review Steps with ValidationNext.js vs Remix in 2026: Which One Should You Use?