How to set up Stripe Checkout in a Next.js app (2026)
Step-by-step: add Stripe Hosted or Embedded Checkout to a Next.js 15 App Router project. Covers Checkout Sessions, webhooks, and the req.text() gotcha.
By Ethan
1,398 words · 7 min read
Stripe Checkout is the fastest way to add payments to a Next.js app. Two modes exist: Hosted (redirects to checkout.stripe.com) and Embedded (renders a Stripe iframe on your own page). Hosted is simpler to integrate; Embedded keeps users on your domain. Both are PCI-compliant — Stripe handles all card data. Pick Hosted first unless your brand demands the iframe.
Who this is for
Developers building a Next.js 15 (App Router) project who need to charge users and want Stripe handling the payment UI. If you need a fully custom payment form, look at Stripe Elements instead — Checkout is not that.
What we tested
stripe npm package v22.2.0, Stripe API version 2026-05-27.dahlia, Next.js 16.x, @stripe/react-stripe-js (Embedded mode only). All snippets below are pulled from working code against these exact versions.
Installation
# Both modes
npm install stripe @stripe/stripe-js
# Embedded mode only
npm install @stripe/react-stripe-js
Set your environment variables in .env.local:
STRIPE_SECRET_KEY=sk_test_51...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51...
STRIPE_WEBHOOK_SECRET=whsec_...
Test vs live mode:
sk_test_andpk_test_keys hit Stripe’s sandbox — no real money moves. For production, swap tosk_live_andpk_live_. Products and prices created in test mode are invisible in live mode; you must create them separately or use the Stripe CLI to seed both.
Create a singleton Stripe client you’ll import on the server side (lib/stripe.ts):
import Stripe from 'stripe'
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2026-05-27.dahlia',
})
Pinning apiVersion shields you from breaking changes when Stripe releases a new version.
Hosted Checkout
The user clicks a button, hits your API route, and lands on checkout.stripe.com. When they complete payment, Stripe redirects them to your success_url.
1. Create a Checkout Session (server)
Add a Route Handler at app/api/checkout/route.ts:
import { stripe } from '@/lib/stripe'
import { NextResponse } from 'next/server'
export async function POST(request: Request) {
const origin = request.headers.get('origin')
const session = await stripe.checkout.sessions.create({
line_items: [
{
price: process.env.STRIPE_PRICE_ID!, // Server-side only — never trust client-supplied amounts
quantity: 1,
},
],
mode: 'payment', // 'payment' | 'subscription' | 'setup'
success_url: `${origin}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${origin}/`,
automatic_tax: { enabled: true },
})
return NextResponse.redirect(session.url!, 303)
}
Always look up the price ID server-side. Never accept a price amount from the client — users can manipulate it.
2. Checkout button (client)
A plain HTML form posts to your Route Handler. No client-side JavaScript needed:
export default function HomePage() {
return (
<form action="/api/checkout" method="POST">
<button type="submit">Buy Now — $20</button>
</form>
)
}
3. Success page
Stripe appends ?session_id=... to your success_url. Retrieve the session server-side to confirm payment:
import { stripe } from '@/lib/stripe'
import { redirect } from 'next/navigation'
export default async function SuccessPage({
searchParams,
}: {
searchParams: Promise<{ session_id?: string }>
}) {
const { session_id } = await searchParams // Next.js 15: searchParams is a Promise
if (!session_id) redirect('/')
const session = await stripe.checkout.sessions.retrieve(session_id, {
expand: ['line_items', 'payment_intent'],
})
if (session.status === 'open') redirect('/') // Payment not complete
return (
<section>
<h1>Payment confirmed!</h1>
<p>
Thank you {session.customer_details?.name}. A receipt was sent to{' '}
{session.customer_details?.email}.
</p>
</section>
)
}
Note await searchParams — in Next.js 15, searchParams and params props are Promises. The Next.js 14 pattern (accessing them directly) throws a runtime error.
Embedded Checkout
The user never leaves your domain. Stripe renders a checkout form inside an iframe on your page. You need a client secret from a Checkout Session and the @stripe/react-stripe-js package.
1. Create the session (server action)
'use server'
import { stripe } from '@/lib/stripe'
import { headers } from 'next/headers'
export async function fetchClientSecret(): Promise<string> {
const origin = (await headers()).get('origin')
const session = await stripe.checkout.sessions.create({
ui_mode: 'embedded_page', // Not 'embedded' — the docs now use 'embedded_page'
line_items: [
{
price: process.env.STRIPE_PRICE_ID!,
quantity: 1,
},
],
mode: 'payment',
return_url: `${origin}/return?session_id={CHECKOUT_SESSION_ID}`,
automatic_tax: { enabled: true },
})
return session.client_secret!
}
2. Render the checkout form (client)
'use client'
import {
EmbeddedCheckout,
EmbeddedCheckoutProvider,
} from '@stripe/react-stripe-js'
import { loadStripe } from '@stripe/stripe-js'
import { fetchClientSecret } from '@/app/actions/stripe'
const stripePromise = loadStripe(
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!
)
export default function CheckoutForm() {
return (
<EmbeddedCheckoutProvider
stripe={stripePromise}
options={{ fetchClientSecret }}
>
<EmbeddedCheckout />
</EmbeddedCheckoutProvider>
)
}
Mount this component on a /checkout page:
import CheckoutForm from '@/components/CheckoutForm'
export default function CheckoutPage() {
return (
<main>
<CheckoutForm />
</main>
)
}
3. Return page
After payment, Stripe calls return_url with ?session_id=...:
import { stripe } from '@/lib/stripe'
import { redirect } from 'next/navigation'
export default async function ReturnPage({
searchParams,
}: {
searchParams: Promise<{ session_id?: string }>
}) {
const { session_id } = await searchParams
if (!session_id) redirect('/')
const session = await stripe.checkout.sessions.retrieve(session_id)
if (session.status === 'open') redirect('/checkout')
return (
<section>
<h1>Payment complete!</h1>
<p>Confirmation sent to {session.customer_details?.email}</p>
</section>
)
}
Hosted vs Embedded: quick comparison
| Hosted Checkout | Embedded Checkout | |
|---|---|---|
| UX | Redirects to checkout.stripe.com | Stays on your domain (iframe) |
| Setup | Simpler — no extra packages | Requires @stripe/react-stripe-js |
| Brand control | Stripe logo visible | Your domain throughout |
| PCI compliance | Stripe handles 100% | Stripe handles 100% |
ui_mode | Omit (or 'hosted') | 'embedded_page' |
| Return param | success_url | return_url |
| Client secret | Not required | Required |
| When to use | Fastest integration | Branded checkout experience |
Webhooks
Both modes need webhooks for reliable fulfillment. Don’t fulfill orders on the success page alone — users can close the tab before it loads.
Add app/api/webhooks/route.ts:
import { stripe } from '@/lib/stripe'
import { headers } from 'next/headers'
export async function POST(req: Request) {
const body = await req.text() // MUST be raw text — req.json() breaks signature verification
const signature = (await headers()).get('stripe-signature')!
let event
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
)
} catch (err: any) {
return new Response(`Webhook error: ${err.message}`, { status: 400 })
}
switch (event.type) {
case 'checkout.session.completed':
const session = event.data.object
// Fulfill here — write to DB, trigger email, provision access
break
}
return new Response(null, { status: 200 })
}
Test locally with the Stripe CLI:
stripe listen --forward-to localhost:3000/api/webhooks
stripe trigger checkout.session.completed
The CLI prints a whsec_... signing secret — paste that into STRIPE_WEBHOOK_SECRET.
Common pitfalls
| Pitfall | What goes wrong | Fix |
|---|---|---|
Using req.json() in the webhook handler | Signature verification fails — body is re-serialized and no longer matches the raw bytes Stripe signed | Always await req.text() |
STRIPE_WEBHOOK_SECRET not set | 400 on every webhook, silent fulfillment gap | Set the env var; run stripe listen to get the local secret |
price ID vs price_data | Confusing two different API overloads | Use a pre-created price ID for fixed products; price_data only for one-off dynamic pricing |
| CORS error on the API route | Client-side fetch hitting a different origin | Use Server Actions (Embedded mode) or set CORS headers in the Route Handler |
Not awaiting searchParams | Runtime error in Next.js 15 | const { session_id } = await searchParams — both props are Promises in Next.js 15 |
| Not checking for duplicate webhooks | Stripe retries failed webhooks — order fulfilled twice | Store session.id and skip if already processed |
| Price from client | User manipulates the amount | Always fetch the price ID server-side; never trust client-supplied numbers |
| Secret key in client bundle | Stripe rejects it; also a security hole | Only NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY goes client-side |
Next steps
Once payments are working, you’ll want to email customers a receipt. Resend is a solid option — fire off a transactional email from your checkout.session.completed webhook handler in a few lines of code.
Deploy the finished app on Vercel — it’s the natural host for Next.js and handles environment variables, edge functions, and preview deployments without configuration.
If you’re still deciding whether Stripe is the right payment processor, see Stripe vs Paddle for a merchant-of-record comparison, or Lemon Squeezy vs Stripe if you need all-in-one billing without managing tax compliance yourself.
References
- Stripe Hosted Checkout quickstart — verified 2026-05-30
- Stripe Embedded Checkout quickstart — verified 2026-05-30
- Next.js Route Handlers — v16.2.6, updated 2026-05-28
- stripe-node v22.2.0 release — confirmed latest