· stripe / nextjs / payments

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

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_ and pk_test_ keys hit Stripe’s sandbox — no real money moves. For production, swap to sk_live_ and pk_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 CheckoutEmbedded Checkout
UXRedirects to checkout.stripe.comStays on your domain (iframe)
SetupSimpler — no extra packagesRequires @stripe/react-stripe-js
Brand controlStripe logo visibleYour domain throughout
PCI complianceStripe handles 100%Stripe handles 100%
ui_modeOmit (or 'hosted')'embedded_page'
Return paramsuccess_urlreturn_url
Client secretNot requiredRequired
When to useFastest integrationBranded 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

PitfallWhat goes wrongFix
Using req.json() in the webhook handlerSignature verification fails — body is re-serialized and no longer matches the raw bytes Stripe signedAlways await req.text()
STRIPE_WEBHOOK_SECRET not set400 on every webhook, silent fulfillment gapSet the env var; run stripe listen to get the local secret
price ID vs price_dataConfusing two different API overloadsUse a pre-created price ID for fixed products; price_data only for one-off dynamic pricing
CORS error on the API routeClient-side fetch hitting a different originUse Server Actions (Embedded mode) or set CORS headers in the Route Handler
Not awaiting searchParamsRuntime error in Next.js 15const { session_id } = await searchParams — both props are Promises in Next.js 15
Not checking for duplicate webhooksStripe retries failed webhooks — order fulfilled twiceStore session.id and skip if already processed
Price from clientUser manipulates the amountAlways fetch the price ID server-side; never trust client-supplied numbers
Secret key in client bundleStripe rejects it; also a security holeOnly 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.

Deploy with Vercel

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

  1. Stripe Hosted Checkout quickstart — verified 2026-05-30
  2. Stripe Embedded Checkout quickstart — verified 2026-05-30
  3. Next.js Route Handlers — v16.2.6, updated 2026-05-28
  4. stripe-node v22.2.0 release — confirmed latest