# How to add Stripe embedded checkout to a Next.js + Supabase app

> Add Stripe embedded checkout to a Next.js app on Supabase: ui_mode embedded_page, a Server Action, a return page and a webhook that grants access.

Source: https://shipanysaas-demo.vercel.app/blog/nextjs-supabase-stripe-embedded-checkout
Published: 2026-10-18

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 `stripe` package on the server, and `@stripe/stripe-js` with `@stripe/react-stripe-js` in the browser. Use a recent `stripe` package: its types follow the API version it pins, and `embedded_page` needs `2026-03-25.dahlia` or later.
- Your secret key in `STRIPE_SECRET_KEY` (server only) and your publishable key in `NEXT_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:

```typescript
// 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](https://docs.stripe.com/checkout/embedded/quickstart) for Next.js creates the session in a Server Action. Add Supabase to it, so the session belongs to whoever is signed in:

```typescript
// 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`:

```tsx
// 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](https://docs.stripe.com/checkout/fulfillment?payment-ui=embedded-page) 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:

```typescript
// 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}`:

```tsx
// 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](https://docs.stripe.com/webhooks) 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:

```typescript
// 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:

```typescript
// 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](https://shipanysaas-demo.vercel.app/blog/stripe-subscriptions-vs-one-time-payments) 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](https://shipanysaas-demo.vercel.app/docs/billing/billing) and [Stripe and Lemon Squeezy](https://shipanysaas-demo.vercel.app/docs/billing/providers). You can click through the app around it in the [live demo](https://shipanysaas-demo.vercel.app/auth/sign-in), and the plans are on the [pricing](https://shipanysaas-demo.vercel.app/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.
