Stripe + Next.js Headless Commerce: The 2026 Integration Guide
I've wired up Stripe to Next.js projects more times than I can count. Every time, the landscape shifts a little — new API versions, new payment methods, new edge cases that bite you at 2 AM when a webhook fails silently. This guide is everything I wish existed the first time I built a headless commerce stack. We'll cover Checkout Sessions, Payment Intents, webhooks, subscriptions, Apple Pay, Google Pay, Link, and one-click checkout. No hand-waving. Actual code, actual architecture decisions, actual gotchas.
As of mid-2026, Stripe's API is at version 2025-12-18.acacia, Next.js 15.x is stable with the App Router as the default, and the @stripe/stripe-js and @stripe/react-stripe-js packages have matured considerably. If you're building on older versions, most of this still applies, but some server action patterns will differ.
Table of Contents
- Why Stripe + Next.js for Headless Commerce
- Architecture Overview
- Setting Up Stripe in a Next.js 15 Project
- Checkout Sessions: The Fast Path
- Payment Intents: Full Control Mode
- Webhook Handling That Actually Works
- Subscriptions and Recurring Billing
- Apple Pay, Google Pay, and Link
- One-Click Checkout with Link
- Security, Testing, and Going Live
- Performance Considerations
- FAQ

Why Stripe + Next.js for Headless Commerce
Stripe processes over $1 trillion in payment volume annually. Next.js powers a growing share of ecommerce storefronts — Vercel reports that over 40% of new Next.js projects in 2025 had some form of commerce functionality. The combination makes sense for a few concrete reasons:
- Server Components and Server Actions let you call the Stripe SDK server-side without building a separate API layer.
- Edge and serverless deployment on Vercel, Netlify, or AWS means your payment endpoints scale automatically.
- React Server Components keep your Stripe secret key on the server where it belongs, without extra gymnastics.
- The App Router gives you layouts, loading states, and error boundaries that map well to checkout flows.
If you're evaluating headless commerce architectures, we've built dozens of these at Social Animal — check out our Next.js development capabilities and headless CMS development for more context on how these pieces fit together.
Architecture Overview
Before writing any code, let's get the architecture right. Here's how the pieces connect in a typical headless commerce setup:
┌─────────────────┐ ┌──────────────────┐ ┌─────────────┐
│ Next.js App │────▶│ Stripe API │────▶│ Webhooks │
│ (App Router) │◀────│ (Server-side) │ │ Endpoint │
└─────────────────┘ └──────────────────┘ └──────┬──────┘
│ │
│ ▼
▼ ┌─────────────┐
┌─────────────────┐ │ Database / │
│ Headless CMS │ │ Order Mgmt │
│ (Products) │ └─────────────┘
└─────────────────┘
The critical decision is whether to use Checkout Sessions (Stripe-hosted or embedded) or Payment Intents (fully custom UI). Here's when to use each:
| Feature | Checkout Sessions | Payment Intents |
|---|---|---|
| Development speed | Fast — days | Slower — weeks |
| UI customization | Limited (Stripe-themed) | Full control |
| PCI compliance scope | SAQ A (simplest) | SAQ A-EP |
| Payment method support | Automatic (40+ methods) | Manual per method |
| Subscription support | Built-in | Requires extra code |
| Apple Pay / Google Pay | Automatic | Manual via Payment Request API |
| Conversion optimization | Stripe-optimized | You're on your own |
| Pricing impact | Same Stripe fees | Same Stripe fees |
My honest recommendation: start with Checkout Sessions unless you have a specific reason not to. You can always migrate to Payment Intents later, and Stripe's embedded checkout has gotten remarkably good in 2025-2026.
Setting Up Stripe in a Next.js 15 Project
Let's set up the foundation. I'm assuming you have a Next.js 15 project with the App Router.
npm install stripe @stripe/stripe-js @stripe/react-stripe-js
Create your environment variables:
# .env.local
STRIPE_SECRET_KEY=sk_test_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
Set up a server-side Stripe instance. I always put this in a lib/stripe.ts file:
// lib/stripe.ts
import Stripe from 'stripe';
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2025-12-18.acacia',
typescript: true,
});
And a client-side loader:
// lib/stripe-client.ts
import { loadStripe } from '@stripe/stripe-js';
export const stripePromise = loadStripe(
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!
);
One thing that trips people up: never import lib/stripe.ts in a client component. The stripe npm package includes your secret key and should only run server-side. Next.js 15 will throw a build error if you accidentally import it in a 'use client' file, which is actually a nice guardrail.

Checkout Sessions: The Fast Path
Checkout Sessions are the fastest way to accept payments. Stripe hosts the payment form (or you embed it), handles PCI compliance, and automatically supports dozens of payment methods including Apple Pay, Google Pay, and Link.
Creating a Checkout Session with Server Actions
// app/actions/checkout.ts
'use server';
import { stripe } from '@/lib/stripe';
import { redirect } from 'next/navigation';
export async function createCheckoutSession(formData: FormData) {
const priceId = formData.get('priceId') as string;
const quantity = Number(formData.get('quantity')) || 1;
const session = await stripe.checkout.sessions.create({
mode: 'payment',
line_items: [
{
price: priceId,
quantity,
},
],
success_url: `${process.env.NEXT_PUBLIC_URL}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_URL}/checkout/canceled`,
automatic_tax: { enabled: true },
// Enable all relevant payment methods
payment_method_types: undefined, // Let Stripe auto-detect
});
redirect(session.url!);
}
Embedded Checkout (2026 Recommended Approach)
Stripe's embedded checkout keeps users on your domain. This has better conversion rates — Stripe's own data from 2025 shows a 10-15% improvement over redirect-based checkout for returning customers.
// app/checkout/embedded/page.tsx
'use client';
import { useCallback } from 'react';
import { loadStripe } from '@stripe/stripe-js';
import {
EmbeddedCheckoutProvider,
EmbeddedCheckout,
} from '@stripe/react-stripe-js';
const stripePromise = loadStripe(
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!
);
export default function CheckoutPage() {
const fetchClientSecret = useCallback(async () => {
const res = await fetch('/api/checkout/embedded', {
method: 'POST',
body: JSON.stringify({ priceId: 'price_xxx', quantity: 1 }),
});
const { clientSecret } = await res.json();
return clientSecret;
}, []);
return (
<div className="max-w-lg mx-auto py-12">
<EmbeddedCheckoutProvider
stripe={stripePromise}
options={{ fetchClientSecret }}
>
<EmbeddedCheckout />
</EmbeddedCheckoutProvider>
</div>
);
}
And the API route:
// app/api/checkout/embedded/route.ts
import { stripe } from '@/lib/stripe';
import { NextResponse } from 'next/server';
export async function POST(req: Request) {
const { priceId, quantity } = await req.json();
const session = await stripe.checkout.sessions.create({
mode: 'payment',
line_items: [{ price: priceId, quantity }],
ui_mode: 'embedded',
return_url: `${process.env.NEXT_PUBLIC_URL}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
});
return NextResponse.json({ clientSecret: session.client_secret });
}
Payment Intents: Full Control Mode
When you need a completely custom checkout UI — maybe you're building a one-page checkout, or your design team has specific requirements — Payment Intents give you full control.
The trade-off is real: you'll write more code, handle more edge cases, and take on a slightly higher PCI compliance burden. But for some products, it's worth it.
Server-Side: Creating Payment Intents
// app/api/payment-intent/route.ts
import { stripe } from '@/lib/stripe';
import { NextResponse } from 'next/server';
export async function POST(req: Request) {
const { amount, currency = 'usd', metadata } = await req.json();
const paymentIntent = await stripe.paymentIntents.create({
amount, // in cents
currency,
metadata,
automatic_payment_methods: {
enabled: true, // This enables Apple Pay, Google Pay, Link, etc.
},
});
return NextResponse.json({
clientSecret: paymentIntent.client_secret,
});
}
Client-Side: The Payment Form
// components/PaymentForm.tsx
'use client';
import { useState } from 'react';
import {
PaymentElement,
useStripe,
useElements,
} from '@stripe/react-stripe-js';
export function PaymentForm() {
const stripe = useStripe();
const elements = useElements();
const [error, setError] = useState<string | null>(null);
const [processing, setProcessing] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!stripe || !elements) return;
setProcessing(true);
setError(null);
const { error: submitError } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: `${window.location.origin}/checkout/success`,
},
});
if (submitError) {
setError(submitError.message ?? 'Payment failed');
setProcessing(false);
}
// If no error, Stripe redirects automatically
};
return (
<form onSubmit={handleSubmit}>
<PaymentElement
options={{
layout: 'accordion',
wallets: {
applePay: 'auto',
googlePay: 'auto',
},
}}
/>
{error && <p className="text-red-500 mt-2">{error}</p>}
<button
type="submit"
disabled={!stripe || processing}
className="mt-4 w-full bg-black text-white py-3 rounded-lg disabled:opacity-50"
>
{processing ? 'Processing...' : 'Pay now'}
</button>
</form>
);
}
Note the automatic_payment_methods: { enabled: true } on the server side. This is the 2025-2026 way to handle payment method support. Stripe will automatically show the right payment methods based on the customer's device, location, and the currency. No more manually listing payment_method_types.
Webhook Handling That Actually Works
Webhooks are where most Stripe integrations break. I've seen production systems lose orders because someone forgot to verify the webhook signature, or because the handler threw an error before sending back a 200.
Here's my battle-tested webhook handler:
// app/api/webhooks/stripe/route.ts
import { stripe } from '@/lib/stripe';
import { headers } from 'next/headers';
import { NextResponse } from 'next/server';
import type Stripe from 'stripe';
export async function POST(req: Request) {
const body = await req.text();
const headersList = await headers();
const signature = headersList.get('stripe-signature');
if (!signature) {
return NextResponse.json({ error: 'Missing signature' }, { status: 400 });
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
console.error('Webhook signature verification failed:', err);
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
}
try {
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
await handleCheckoutComplete(session);
break;
}
case 'payment_intent.succeeded': {
const paymentIntent = event.data.object as Stripe.PaymentIntent;
await handlePaymentSuccess(paymentIntent);
break;
}
case 'payment_intent.payment_failed': {
const paymentIntent = event.data.object as Stripe.PaymentIntent;
await handlePaymentFailure(paymentIntent);
break;
}
case 'customer.subscription.created':
case 'customer.subscription.updated':
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription;
await handleSubscriptionChange(subscription);
break;
}
case 'invoice.payment_failed': {
const invoice = event.data.object as Stripe.Invoice;
await handleInvoiceFailure(invoice);
break;
}
default:
console.log(`Unhandled event type: ${event.type}`);
}
} catch (err) {
console.error(`Error processing ${event.type}:`, err);
// Still return 200 to prevent Stripe from retrying
// Log the error for manual investigation
}
return NextResponse.json({ received: true });
}
Webhook Gotchas I've Learned the Hard Way
Always return 200, even if your processing fails. Otherwise Stripe retries, and you might process the same event multiple times. Log the error and deal with it asynchronously.
Make handlers idempotent. Stripe can and will send the same event more than once. Use the event ID or the object's metadata to check if you've already processed it.
Use
req.text()notreq.json()for signature verification. The signature is computed over the raw body string. If you parse it first, verification will always fail.Set up the Stripe CLI for local testing. It's non-negotiable.
stripe listen --forward-to localhost:3000/api/webhooks/stripe
- On Vercel, webhook routes need specific config. Make sure your route isn't behind any middleware that modifies the request body. In Next.js 15, API routes in the App Router handle this correctly by default, but double-check if you have custom middleware.
Subscriptions and Recurring Billing
Subscriptions add a layer of complexity. You're not just handling a one-time payment — you're managing a lifecycle: trials, upgrades, downgrades, cancellations, failed payments, dunning.
Creating a Subscription via Checkout
The easiest approach:
// app/actions/subscribe.ts
'use server';
import { stripe } from '@/lib/stripe';
import { redirect } from 'next/navigation';
export async function createSubscriptionCheckout(
customerId: string,
priceId: string
) {
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
customer: customerId,
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_URL}/account/billing?success=true`,
cancel_url: `${process.env.NEXT_PUBLIC_URL}/pricing`,
subscription_data: {
trial_period_days: 14,
metadata: {
plan: 'pro', // Your own metadata
},
},
allow_promotion_codes: true,
tax_id_collection: { enabled: true },
});
redirect(session.url!);
}
Managing Subscriptions
For the customer portal (upgrade, downgrade, cancel, update payment method), Stripe's Customer Portal is genuinely great in 2026:
// app/actions/billing.ts
'use server';
import { stripe } from '@/lib/stripe';
import { redirect } from 'next/navigation';
export async function createBillingPortalSession(customerId: string) {
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${process.env.NEXT_PUBLIC_URL}/account/billing`,
});
redirect(session.url);
}
Key Subscription Webhook Events
| Event | When It Fires | What To Do |
|---|---|---|
customer.subscription.created |
New subscription | Provision access |
customer.subscription.updated |
Plan change, renewal | Update access level |
customer.subscription.deleted |
Cancellation (end of period) | Revoke access |
invoice.payment_succeeded |
Successful renewal | Update billing records |
invoice.payment_failed |
Failed renewal | Send dunning email, flag account |
customer.subscription.trial_will_end |
3 days before trial ends | Send reminder email |
Don't rely solely on the subscription status from the API call. Webhooks are the source of truth for subscription state changes. I've seen teams poll the Stripe API instead of using webhooks and it's both slower and more fragile.
Apple Pay, Google Pay, and Link
The beauty of Stripe's 2025-2026 Payment Element is that wallet payments mostly just work. But there are some setup requirements people miss.
Apple Pay Setup
Domain verification is required. You need to host a
.well-known/apple-developer-merchantid-domain-associationfile at your domain root. Stripe provides this file in your Dashboard under Settings → Payment Methods → Apple Pay.In Next.js, place the file at
public/.well-known/apple-developer-merchantid-domain-association.Register your domain in the Stripe Dashboard.
Apple Pay only shows up on Safari/iOS. Don't be alarmed when it doesn't appear in Chrome during testing.
Google Pay Setup
Google Pay requires less setup — it works automatically with the Payment Element as long as your Stripe account is properly configured. It shows up in Chrome and on Android devices.
Link (Stripe's One-Click Checkout)
Link is Stripe's answer to Shop Pay. Customers save their payment info once and can check out with one click across any Stripe merchant using Link.
As of 2026, Link is enabled by default on new Stripe accounts. The conversion uplift is real — Stripe reports 7% higher checkout completion when Link is available. For returning Link users, it's much higher.
With the Payment Element, Link appears automatically. With Checkout Sessions, it's also automatic. You don't need to do anything special.
// Link is automatic with Payment Element, but you can customize:
<PaymentElement
options={{
wallets: {
applePay: 'auto',
googlePay: 'auto',
},
// Link shows up in the email field automatically
}}
/>
One-Click Checkout with Link
Link deserves its own section because it's become a serious conversion driver. Here's how it works:
- Customer enters their email in your checkout form.
- If they have a Link account, they receive a verification code via SMS.
- After verification, their saved address and payment method are auto-filled.
- They click "Pay" — done.
The key insight: Link works across merchants. If your customer used Link on a completely different site, they'll get the one-click experience on yours too. Stripe's network effect is real — they report over 100 million Link users as of early 2026.
To maximize Link adoption, make sure the email field is the first thing customers interact with in your checkout flow. The Payment Element handles this well with the accordion layout.
If you want to go further, you can use the Express Checkout Element to show Apple Pay, Google Pay, and Link as prominent buttons above your form:
// components/ExpressCheckout.tsx
'use client';
import { ExpressCheckoutElement } from '@stripe/react-stripe-js';
export function ExpressCheckout() {
return (
<ExpressCheckoutElement
onConfirm={async (event) => {
// Handle the express payment confirmation
console.log('Express checkout confirmed:', event);
}}
options={{
buttonType: {
applePay: 'buy',
googlePay: 'buy',
},
}}
/>
);
}
Security, Testing, and Going Live
Security Checklist
- Stripe secret key is only used server-side
- Webhook signatures are verified on every request
- HTTPS is enforced in production
- Amount calculations happen server-side (never trust client-sent amounts)
- API routes have rate limiting
- Customer data is handled per your privacy policy
- CSP headers allow Stripe's domains (
js.stripe.com,api.stripe.com)
Testing
Stripe's test mode is excellent. Use these test card numbers:
| Card Number | Scenario |
|---|---|
4242 4242 4242 4242 |
Successful payment |
4000 0000 0000 3220 |
3D Secure required |
4000 0000 0000 9995 |
Declined |
4000 0025 0000 3155 |
Requires authentication |
4000 0000 0000 0341 |
Attach fails (for saved cards) |
For subscription testing, use Stripe's test clocks to simulate time passing without actually waiting.
Going Live
- Switch your keys from
sk_test_tosk_live_andpk_test_topk_live_. - Set up your live webhook endpoint in the Stripe Dashboard.
- Verify your Apple Pay domain for production.
- Enable the payment methods you want in the Stripe Dashboard.
- Make sure your Stripe account is fully activated (identity verification, bank account, etc.).
Performance Considerations
Stripe.js is ~40KB gzipped. That's not nothing. Here are some tips:
Lazy load Stripe.js. Don't load it on every page — only on checkout-related pages. The
loadStripefunction handles this well; it won't fetch the script until you call it.Use
@stripe/stripe-js/pureif you want to control exactly when the script loads:
import { loadStripe } from '@stripe/stripe-js/pure';
// Script won't load until loadStripe() is called
Server Components for product pages. Keep Stripe client code out of your product listing and detail pages. Only bring in the client components when the user actually initiates checkout.
Edge runtime for API routes. Stripe's Node.js SDK works on the Edge runtime as of 2025. You can add
export const runtime = 'edge'to your Stripe API routes for lower latency.
For teams building high-performance headless storefronts, frameworks like Astro can also be a great fit for the content-heavy pages while Next.js handles the dynamic checkout flows. We've done this hybrid approach for several clients — our Astro development and Next.js development teams collaborate on these architectures regularly.
FAQ
What are Stripe's transaction fees in 2026?
Stripe's standard pricing is 2.9% + $0.30 per successful card charge in the US. For European cards, it's 1.5% + €0.25. Volume discounts are available for businesses processing over $1M annually. There are no setup fees, monthly fees, or hidden charges on the standard plan. Stripe charges an additional 0.5% for manually entered cards and 1% for international cards.
Should I use Checkout Sessions or Payment Intents?
Use Checkout Sessions for most cases. They're faster to implement, automatically support 40+ payment methods, handle PCI compliance, and Stripe continuously optimizes the conversion rate. Use Payment Intents when you need a completely custom checkout UI that can't be achieved with embedded Checkout, or when you need fine-grained control over the payment flow (like split payments or manual capture).
How do I handle webhook failures in production?
Always return a 200 status code from your webhook handler, even if your business logic fails. Log the error and process it asynchronously. Make your handlers idempotent by checking the event ID against your database before processing. Stripe retries webhooks for up to 3 days with exponential backoff. Set up webhook failure alerts in the Stripe Dashboard, and consider using a queue (like AWS SQS or Inngest) for processing webhook payloads asynchronously.
Can I use Stripe with a headless CMS like Sanity or Contentful?
Absolutely. The typical pattern is: store product information and content in your headless CMS, store prices and payment data in Stripe, and connect them via a shared product ID or SKU. Your Next.js frontend fetches content from the CMS and creates Stripe Checkout Sessions or Payment Intents when the customer is ready to buy. We cover this pattern extensively in our headless CMS development work.
How do I test Apple Pay locally?
You can't easily test Apple Pay on localhost because it requires HTTPS and domain verification. The best approach is to use Stripe's test mode with the 4242 test card in the Payment Element — it simulates the payment flow. For actual Apple Pay testing, deploy to a staging environment with HTTPS. The Stripe CLI also supports forwarding webhook events for Apple Pay transactions.
Is Stripe Link worth enabling?
Yes. Link is free for merchants — Stripe doesn't charge extra for it. It shows up automatically in the Payment Element and Checkout Sessions. Stripe reports that Link increases checkout completion by up to 7% on average, with higher numbers for returning Link users. There's no downside to enabling it, and with 100M+ Link users in 2026, the network effect is significant.
How do I handle subscriptions with metered billing in Next.js?
Create a subscription with a metered price in Stripe. Then, use the Usage Records API to report usage from your backend. At the end of each billing period, Stripe automatically calculates the total and charges the customer. Your webhook handler should listen for invoice.payment_succeeded and invoice.payment_failed events to keep your system in sync. Report usage server-side using a cron job or event-driven architecture.
What's the best way to handle currency and pricing for international customers?
Stripe Adaptive Pricing (launched 2025) automatically converts prices to the customer's local currency at checkout. You set prices in your base currency, and Stripe handles conversion, display, and settlement. Alternatively, you can create multiple prices per product in different currencies in Stripe for more control. Use the customer's IP or browser locale to determine which currency to display on your product pages.
How much does it cost to build a headless commerce integration with Stripe?
It depends on scope. A basic Checkout Sessions integration can be done in a few days. A full-featured setup with subscriptions, customer portal, webhooks, and custom UI typically takes 2-6 weeks of development time. If you want to discuss your specific needs, check out our pricing page or get in touch — we've built these integrations across a wide range of industries and can give you a realistic estimate.