Stripe embedded checkout puts Stripe's payment form inside your own page, so customers pay without leaving your app. In a Next.js App Router app on Supabase, it takes four pieces: a Checkout Session created on the server for the signed-in user, a Client Component that mounts it, a return page, and a webhook that records the payment in your database.
How do you add it? Create the session in a Server Action with ui_mode: 'embedded_page' and a return_url, taking the user's id from their Supabase session, never from the browser. Hand its client secret to EmbeddedCheckoutProvider from @stripe/react-stripe-js. Then grant access from one fulfillment function that both the return page and a verified webhook call, writing to Supabase with the secret key. One detail trips up older tutorials: since Stripe's 2026-03-25.dahlia API version, the value is embedded_page, and the old embedded fails.
Stripe embedded checkout vs the hosted page
Both use the same Checkout Session API. The difference is where the form appears:
Embedded page (embedded_page) | Stripe-hosted page (hosted_page) | |
|---|---|---|
| Where customers pay | In an iframe on your page | On a Stripe checkout page they are redirected to |
| After paying | Stripe sends them to your return_url | Stripe sends them to your success_url |
| Frontend work | Stripe.js and a small component | A redirect |
cancel_url | Not allowed | Supported, shows a back button |
hosted_page is the default. The hosted page is the quickest to build and suits payment links and sales pages. The embedded page suits a SaaS dashboard, where you want the customer to stay inside your app while they pay. Stripe's API version naming also changed the other values: hosted became hosted_page, and custom became elements.
What you need first
- The
stripepackage on the server, and@stripe/stripe-jswith@stripe/react-stripe-jsin the browser. Use a recentstripepackage: its types follow the API version it pins, andembedded_pageneeds2026-03-25.dahliaor later. - Your secret key in
STRIPE_SECRET_KEY(server only) and your publishable key inNEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY. - A product with a price in the Stripe Dashboard, in test mode while you build.
- Supabase Auth set up with
@supabase/ssr, so the server knows who is paying.
Keep the Stripe client in a server-only module:
// lib/stripe.ts import 'server-only'; import Stripe from 'stripe'; export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
Step 1: create the Stripe checkout session for the signed-in user
Stripe's embedded checkout quickstart for Next.js creates the session in a Server Action. Add Supabase to it, so the session belongs to whoever is signed in:
// app/checkout/actions.ts
'use server';
import { headers } from 'next/headers';
import { stripe } from '@/lib/stripe';
import { createClient } from '@/lib/supabase/server';
export async function fetchClientSecret() {
const supabase = await createClient();
const { data } = await supabase.auth.getClaims();
const userId = data?.claims.sub;
if (!userId) {
throw new Error('You need to sign in first');
}
const origin = (await headers()).get('origin');
const session = await stripe.checkout.sessions.create({
ui_mode: 'embedded_page',
mode: 'subscription',
line_items: [{ price: process.env.STRIPE_PRICE_ID!, quantity: 1 }],
client_reference_id: userId,
subscription_data: { metadata: { userId } },
return_url: `${origin}/checkout/return?session_id={CHECKOUT_SESSION_ID}`,
});
return session.client_secret!;
}
The user id comes from getClaims(), which verifies the session token. A version that accepts a user id from the browser lets anyone attach a payment to someone else's account. Take the price id from your own config as well, so nobody can check out with a price you don't offer.
client_reference_id ties the Checkout Session to your user. For a subscription, copy the id into subscription_data.metadata too, because later renewal and cancellation events point to the subscription, not to this session. Use mode: 'payment' for a one-time price. If the user is already a Stripe customer, pass customer so you don't create a duplicate.
Step 2: mount the embedded checkout
The Server Action can be passed straight to the provider as fetchClientSecret:
// app/checkout/checkout.tsx
'use client';
import {
EmbeddedCheckout,
EmbeddedCheckoutProvider,
} from '@stripe/react-stripe-js';
import { loadStripe } from '@stripe/stripe-js';
import { fetchClientSecret } from './actions';
const stripePromise = loadStripe(
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!,
);
export function Checkout() {
return (
<EmbeddedCheckoutProvider
stripe={stripePromise}
options={{ fetchClientSecret }}
>
<EmbeddedCheckout />
</EmbeddedCheckoutProvider>
);
}
Render <Checkout /> on a page behind your sign-in check, such as app/checkout/page.tsx. Call loadStripe once, outside the component, so it isn't re-created on every render. If you use Stripe.js without React, note the other rename in the same API version: stripe.initEmbeddedCheckout() is now stripe.createEmbeddedCheckoutPage().
Step 3: write one fulfillment function
Stripe's fulfillment guide asks for a webhook, because a customer can pay and lose their connection before your page loads. It also recommends fulfilling from the return page, because webhooks can arrive late. Both should call one function that is safe to run twice:
// lib/fulfill.ts
import 'server-only';
import { stripe } from '@/lib/stripe';
export async function fulfillCheckout(sessionId: string) {
const session = await stripe.checkout.sessions.retrieve(sessionId);
if (session.payment_status === 'unpaid' || !session.client_reference_id) {
return;
}
// upsert the order or subscription for session.client_reference_id,
// keyed on Stripe's ids, with the secret-key Supabase client
}
The function reads the session from Stripe instead of trusting whoever called it. payment_status matters: with a delayed payment method, such as a bank debit, Checkout completes while the payment is still unpaid, and the money arrives later.
The write needs care on the Supabase side. Nobody is signed in on a webhook request, and users should never be able to write billing rows themselves. Give the billing tables a select policy for their owners and no insert or update grants for authenticated. Write with a client built on the secret key, which bypasses row-level security and stays in server-only code. Use upserts keyed on Stripe's ids, so running the function twice rewrites the same row.
Step 4: call it from the return page
After payment, Stripe sends the customer to your return_url, with the session id in place of {CHECKOUT_SESSION_ID}:
// app/checkout/return/page.tsx
import { redirect } from 'next/navigation';
import { fulfillCheckout } from '@/lib/fulfill';
import { stripe } from '@/lib/stripe';
export default async function ReturnPage({
searchParams,
}: {
searchParams: Promise<{ session_id?: string }>;
}) {
const { session_id } = await searchParams;
const session = session_id
? await stripe.checkout.sessions.retrieve(session_id).catch(() => null)
: null;
if (session?.status !== 'complete') {
redirect('/checkout');
}
await fulfillCheckout(session.id);
return session.payment_status === 'unpaid' ? (
<p>Payment processing. Your plan starts when it clears.</p>
) : (
<p>Payment received. Your plan is ready.</p>
);
}
open means the payment failed or was cancelled, and expired that the session ran out, so both go back to checkout. A made-up session_id makes retrieve() throw, which the catch turns into the same redirect instead of an error page.
Step 5: call it from a webhook
Stripe's webhook guide asks for three things: verify the Stripe-Signature header against the raw body, return a 2xx quickly, and expect retries. In live mode, Stripe retries a failed delivery for up to three days. In a route handler, request.text() gives you the raw body:
// app/api/stripe/webhook/route.ts
import type Stripe from 'stripe';
import { fulfillCheckout } from '@/lib/fulfill';
import { stripe } from '@/lib/stripe';
export async function POST(request: Request) {
const body = await request.text();
const signature = request.headers.get('stripe-signature') ?? '';
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch {
return new Response('Invalid signature', { status: 400 });
}
switch (event.type) {
case 'checkout.session.completed':
case 'checkout.session.async_payment_succeeded':
await fulfillCheckout(event.data.object.id);
break;
case 'checkout.session.async_payment_failed':
// tell the customer the payment didn't go through
break;
}
return new Response(null, { status: 200 });
}
Keep the route out of your sign-in redirect. Supabase's example proxy.ts sends every signed-out request to /login, and its matcher includes /api routes. Stripe's requests carry no session, and Stripe counts a redirect as a failed delivery, so exclude the path in the matcher:
// proxy.ts
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|api/stripe/webhook|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
};
For subscriptions, also handle customer.subscription.updated and customer.subscription.deleted, so renewals and cancellations reach your database. Stripe doesn't promise to deliver events in order, so fetch the current subscription with stripe.subscriptions.retrieve() and write that, not the copy in the event. The Stripe subscriptions guide lists the events worth handling and what each one means.
Testing it
Use test-mode keys and the card 4242 4242 4242 4242 with any future date. To receive webhooks locally, the Stripe CLI forwards events to your route and prints a whsec_ signing secret for STRIPE_WEBHOOK_SECRET. In the Dashboard's event log you can see each delivery and resend one that failed.
If a coding agent writes this for you, two checks catch most mistakes: the session must take the user from the server session, and no route may write billing rows with a key the browser can see.
How the kit does it
shipanysaas ships this flow for Stripe, and the same billing pages work with Lemon Squeezy. Embedded checkout is the default: STRIPE_UI_MODE is embedded_page unless you set hosted_page. create-stripe-checkout.ts in packages/billing/stripe builds the session. For embedded checkout it sets a return_url carrying {CHECKOUT_SESSION_ID}, and for the hosted page a success_url and cancel_url. The API version is pinned to 2026-07-29.dahlia in stripe-sdk.ts.
The session is bound to an account, which can be a person or a team. client_reference_id holds the account id, and the server sets subscription_data.metadata.accountId after any caller metadata, so it can't be overridden. A returning customer's Stripe id is reused, and one-time buyers get a customer record too. The form opens in a dialog through EmbeddedCheckoutProvider.
The webhook at /api/billing/webhook verifies the signature with constructEventAsync and handles the delayed-payment events too. It writes subscriptions and orders with upserts, reading a subscription back from Stripe on updates. The kit's proxy.ts skips /api routes, so the webhook is never redirected to sign-in. Signed-in users can only read the billing tables, so nobody can give themselves a paid plan. pnpm stripe:listen forwards test events locally, and plans live in apps/web/config/billing.config.ts. Checkout stays on the web; the phone app shows the plan as a read-only badge. The kit also includes Stripe's own stripe-best-practices skill for your agent.
The reference is in Billing and Stripe and Lemon Squeezy. You can click through the app around it in the live demo, and the plans are on the pricing page.
Questions people ask
What is the difference between embedded and embedded_page?
They are the same mode under two API versions. Stripe renamed the values in 2026-03-25.dahlia, and the old ones fail on that version and later. Don't count on TypeScript to warn you: the stripe package's type for ui_mode also accepts any string, so an old value only fails when Stripe's API rejects the request. Check the version your package pins; stripe 22.5.0, for example, pins 2026-07-29.dahlia.
Can I use embedded checkout for subscriptions and one-time payments?
Yes. Set mode: 'subscription' with a recurring price, or mode: 'payment' with a one-time price. The component and return page stay the same.
Do I still need a webhook if the return page loads?
Yes. The return page can be skipped, closed or reloaded, and payment methods that confirm later finish after the customer has gone. Only the webhook is certain to arrive.
How do I link a Stripe customer to a Supabase user?
Set client_reference_id to the user's id from their verified session, and copy it into the subscription's metadata. A subscription always creates a Stripe customer; in payment mode, set customer_creation: 'always' to get one. Store the customer id against the user when you fulfill, and pass it as customer next time.