Implementing Stripe in Next.js: Best Practices
A production-ready guide to integrating Stripe payments in Next.js — covering Server Actions, webhooks, security, and the mistakes that cost real money.
Payment bugs are not like other bugs. A broken navigation link is embarrassing. A broken checkout loses you money in real time, at 2am, while you sleep. We've inherited enough half-built Stripe integrations to know that payment processing is one of those areas where "it mostly works" isn't good enough.
The good news is that Stripe is genuinely excellent software, and Next.js is one of the best frameworks to integrate it with. The bad news is that the obvious approach — following a quick tutorial and shipping — leaves a long list of subtle problems that only surface in production. This guide covers how to do it properly.
Why so many Stripe integrations go wrong
Before the practical advice, it's worth understanding why this keeps happening.
Stripe's documentation is good, but it's also vast. There are multiple ways to handle payments (Checkout, Payment Intents, Payment Links, subscriptions), multiple webhook event types, and a security model that requires careful implementation to not accidentally expose secret keys or trust unverified webhook data.
The most common failure modes we see:
- Secret keys on the client side. This is catastrophic — an exposed
STRIPE_SECRET_KEYgives an attacker full control of your Stripe account. - Skipping webhook verification. Processing orders based on unverified POST requests means anyone can fake a payment success event and get goods for free.
- Fulfilling orders in the checkout success handler. The redirect URL is not a reliable signal — users can navigate directly to it, or the webhook can arrive before the redirect.
- No idempotency on webhooks. Stripe may deliver the same event more than once. If your fulfilment logic runs twice, you double-charge, double-dispatch, or create duplicate accounts.
- Test mode in production. It happens more than you'd think.
None of these are obscure edge cases. They're the first things a malicious user will try.
Setting up your environment correctly
Start with environment variables. In Next.js, variables prefixed with NEXT_PUBLIC_ are exposed to the browser. Everything else stays server-side only.
# .env.local
STRIPE_SECRET_KEY=sk_live_... # Server only — NEVER prefix with NEXT_PUBLIC_
STRIPE_WEBHOOK_SECRET=whsec_... # Server only
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_... # Safe to expose — this is its purposeCreate a single Stripe client instance and reuse it throughout your server-side code:
// lib/stripe.ts
import Stripe from 'stripe';
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-06-20',
typescript: true,
});Using the explicit apiVersion prevents Stripe from silently upgrading the API behaviour when they release a new version — breaking changes have caught teams off guard before.
Server Actions vs API Routes
In the App Router era, you have two sensible options for server-side Stripe operations: Route Handlers (the successor to API Routes) and Server Actions.
Route Handlers (app/api/stripe/route.ts) are the right choice for webhook endpoints — Stripe needs a raw HTTP endpoint it can POST to, and Server Actions don't expose that.
Server Actions are cleaner for user-initiated operations like creating a Checkout session. They let you call server-side code directly from a form or button without an intermediate fetch:
// app/actions/checkout.ts
'use server';
import { stripe } from '@/lib/stripe';
import { redirect } from 'next/navigation';
export async function createCheckoutSession(priceId: string) {
const session = await stripe.checkout.sessions.create({
mode: 'payment',
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_BASE_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_BASE_URL}/pricing`,
});
redirect(session.url!);
}The key point here: no secret key is ever sent to the browser. The action runs entirely on the server.
Webhooks: the part most tutorials skip
Webhooks are where Stripe tells your server that something happened — a payment succeeded, a subscription renewed, a refund was issued. Getting webhooks right is the most important thing you'll do in a Stripe integration.
Here's a correct webhook handler:
// app/api/stripe/webhook/route.ts
import { stripe } from '@/lib/stripe';
import { headers } from 'next/headers';
import type { NextRequest } from 'next/server';
export async function POST(request: NextRequest) {
const body = await request.text(); // Must be raw text, not parsed JSON
const signature = headers().get('stripe-signature')!;
let event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
console.error('Webhook signature verification failed:', err);
return new Response('Invalid signature', { status: 400 });
}
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object;
await fulfillOrder(session);
break;
}
case 'payment_intent.payment_failed': {
const paymentIntent = event.data.object;
await handleFailedPayment(paymentIntent);
break;
}
}
return new Response('OK', { status: 200 });
}Three things to note:
**1. Use request.text(), not request.json().** The webhook signature is computed against the raw request body. Once you parse it as JSON, the signature check will fail because whitespace may differ. This is the most common reason webhook verification mysteriously breaks.
2. Always verify before processing. stripe.webhooks.constructEvent checks the signature cryptographically. If it throws, reject the request immediately. Never process webhook data before this check.
3. Return 200 quickly. Stripe will retry webhooks that return non-200 responses. Return 200 as soon as you've acknowledged the event, even if your fulfilment logic runs asynchronously.
Idempotency: handling duplicate events
Stripe guarantees at-least-once delivery, not exactly-once. Under network issues or retries, the same event can arrive multiple times. Your webhook handler must be idempotent — processing the same event twice should have no visible side effect.
The simplest approach is to store processed event IDs:
async function fulfillOrder(session: Stripe.Checkout.Session) {
const alreadyProcessed = await db.processedEvents.findUnique({
where: { stripeEventId: session.id },
});
if (alreadyProcessed) return; // Already handled — safe to ignore
// Process the order...
await db.orders.create({ ... });
// Mark as processed
await db.processedEvents.create({
data: { stripeEventId: session.id },
});
}This is especially critical for subscription billing, where webhooks fire on a recurring schedule and a double-processed renewal could incorrectly extend or reset a billing cycle.
Fulfil on webhook, not on redirect
This is worth stating plainly: never fulfil an order based on a redirect to your success page. The /success?session_id=... URL pattern is only for user-facing confirmation. The actual fulfilment — provisioning access, sending confirmation emails, updating your database — must happen in the webhook handler.
Why? Because the redirect can fail. The user might close their browser. Network issues might prevent the redirect. Meanwhile, Stripe has taken the money and will reliably deliver the webhook.
The correct flow:
1. User completes payment on Stripe Checkout
2. Stripe redirects to your /success page (for user experience only)
3. Stripe sends checkout.session.completed webhook to your server
4. Your webhook handler fulfils the order
On the /success page, you can use the session_id query param to fetch order status from your database and show the user appropriate confirmation — but only after your webhook has processed it.
Testing your integration properly
Stripe provides test card numbers, webhooks forwarding via the Stripe CLI, and a full test mode environment. Use all of it.
The Stripe CLI's webhook forwarding is essential for local development:
stripe listen --forward-to localhost:3000/api/stripe/webhookThis forwards real webhook events from your Stripe test account to your local server. It also prints your local webhook secret — use this in your .env.local for development.
Test the unhappy paths as much as the happy path. Use Stripe's card numbers for declined payments (4000000000000002), authentication required (4000002500003155), and insufficient funds (4000000000009995). Most payment integrations handle the success case correctly and fall apart on failure.
Key takeaways
- **Never expose
STRIPE_SECRET_KEYto the client** — prefix nothing Stripe-sensitive withNEXT_PUBLIC_ - Verify every webhook signature before processing —
stripe.webhooks.constructEventis non-negotiable - Use raw request body for webhook verification — parsed JSON breaks the signature check
- Fulfil orders in webhook handlers, not success redirects — webhooks are reliable; redirects are not
- Make webhook handlers idempotent — store processed event IDs to handle duplicate delivery safely
- Pin your API version in the Stripe client constructor — avoid silent breaking changes
- Test declined and failed payment paths — they're where real-world bugs live
Building a payment integration that won't keep you up at night
A Stripe integration that works in testing but breaks silently in production is unfortunately common. The failure modes we've described aren't hypothetical — we've been called in to fix all of them on real live sites. Security issues in particular tend to go unnoticed until they're actively being exploited.
We build payment integrations as a standard part of our client projects, whether that's simple one-time checkout flows, recurring subscription billing, or complex multi-party transactions. If you're planning a new build that involves payments, or you've inherited an integration you're not confident in, get in touch — we're happy to review what you have and give you a straight assessment.
Need help implementing this?
We build high-performance websites and automate workflows for ambitious brands. Let's talk about how we can help your business grow.
More Articles
Next.js Image Optimisation: The Complete Guide
Master Next.js image optimisation with the Image component, lazy loading, responsive sizing, and format conversion to dramatically improve page speed and Core Web Vitals.

Next.js App Router: A Practical Migration Guide
Still on the Pages Router? Here's how to migrate to Next.js App Router incrementally — without breaking production or rewriting everything at once.