Polar checkout in Next.js: hosted vs embedded, and what useDeploy uses

Polar hosted vs embedded checkout in Next.js with @polar-sh/sdk 1.x: create the session on the server, redirect, and what embedding adds on top.

MC
Martin Coll

Polar gives you two ways to take a payment from a Next.js app. With hosted checkout, your server creates a checkout session, gets back a url, and the browser is redirected to a Polar-hosted page. With embedded checkout, the same kind of URL opens inside an iframe overlay on your own page, which additionally needs an embed_origin, an allow-list of your domains in Polar's settings and the @polar-sh/checkout library. useDeploy uses hosted checkout, and so does the code below; the last section shows what embedding would add and is not something useDeploy ships. The snippets are from the Polar adapter on @polar-sh/sdk 1.x (1.0.2, imported from the dated @polar-sh/sdk/2026-10 entry point), the version useDeploy pins.

Create the session on the server

The checkout is created from your backend with your access token, never from the browser. The create session docs only require one thing, a list of product IDs. useDeploy's adapter (apps/server/src/modules/billing/infrastructure/providers/polar-payment-provider.ts) passes a few more:

import { createPolar } from '@polar-sh/sdk/2026-10';

const client = createPolar({
  accessToken: cfg.accessToken,
  environment: cfg.server ?? 'production', // 'sandbox' | 'production'
  timeout: 30, // seconds; the SDK 1.x default is 5
});

const checkout = await client.checkouts.create({
  products: input.lineItems.map((li) => li.priceId),
  external_customer_id: input.customer.internalId,
  customer_email: input.customer.email,
  customer_name: input.customer.name,
  success_url: input.successUrl,
  metadata: input.metadata,
});

return ok({ id: checkout.id, url: checkout.url, provider: 'polar' });

What each field does, per the API reference:

  • products: product IDs the customer can pick from; the first is selected by default. This is the only required field.
  • external_customer_id: your own user id. If no Polar customer matches, one is created with it, and webhooks later carry it as customer.external_id. That is how useDeploy maps a payment back to a user or organization without storing Polar's ids first. Setting it also pre-fills and locks the email field.
  • success_url: where the customer lands after paying.
  • metadata: key-value pairs copied onto the resulting order or subscription.
  • customer_email / customer_name: prefill.

SDK 1.x does not convert field names: requests and responses are snake_case, so external_customer_id is the name on the wire and in your code. Polar isolates sandbox and production completely (separate organizations, products and tokens), which is why the client takes an environment. The adapter itself falls back to production, but useDeploy's POLAR_SERVER env var defaults to sandbox outside production and must be set explicitly in production, so a deployment never silently picks one. A sandbox token against the production API fails loudly instead of silently charging nothing.

Redirect the browser

The adapter sits behind a provider-agnostic route, POST /api/v1/billing/checkout (cookie session required). The pricing page calls it and navigates to the URL it gets back:

const session = await startCheckoutMutation.mutateAsync({
  priceId,
  mode: 'payment',
  successUrl: `${window.location.origin}/checkout/success?checkout_id={CHECKOUT_ID}`,
  cancelUrl: `${window.location.origin}/resources/pricing`,
  metadata: { slug },
});
window.location.assign(session.url);

{CHECKOUT_ID} is a placeholder Polar replaces with the real session id when it redirects, as described in the API reference ("append a checkout_id={CHECKOUT_ID} query parameter"). Keep the braces in the string you send; do not template it yourself. The success page can use the id for analytics or to show a receipt, but treat it as a hint.

Do not grant access from the success page. The redirect can be skipped (closed tab) or forged (anyone can type the URL). The webhook is the source of truth: see Polar webhooks in Next.js.

Cancel and back buttons

The adapter's port takes both successUrl and cancelUrl (camelCase, it is useDeploy's own port, not the Polar API), and the Polar adapter ignores cancelUrl: the session create call has no cancel field, so a customer who abandons checkout is not sent anywhere on your site. The API reference does list a return_url that adds a back button inside checkout. useDeploy does not pass it. If you want a way back to your pricing page, add return_url to the call.

Hosted or embedded

HostedEmbedded
Customer experienceLeaves your site, returns to success_urlOverlay on your page
Extra setupNoneembed_origin, allowed domains in Polar settings, HTTPS, @polar-sh/checkout
Apple Pay and Google PayWork without extra setupDisabled by default; Polar enables them on request per domain
Failure modeRedirect fails visiblyAn iframe blocked by frame-ancestors if the domain is not allow-listed
Code on the clientwindow.location.assign(url)Library init plus event handling

For a SaaS where checkout is a rare, high-intent action, hosted is the lower-risk default and it is what useDeploy ships. The conversion upside of embedding is real for some products, but you take on the allow-list and the wallet limitation.

What embedding would add

This is from the embedded checkout docs, not from useDeploy. The adapter does not set embed_origin, the client has no @polar-sh/checkout dependency, and nothing in the repo opens an iframe. To embed, you would make three changes:

  1. Create the session with the origin of the page that will host the iframe. embed_origin exists on the checkout create input in @polar-sh/sdk 1.0.2:

    const checkout = await client.checkouts.create({
      products: [...],
      embed_origin: 'https://app.example.com',
      // ...the same fields as above
    });
  2. Add each embedding domain under Settings, then Embedding, in the Polar dashboard. Entries are written without a scheme, and public sites must be HTTPS (plain HTTP is accepted only for local hosts). A console error mentioning frame-ancestors means the domain is missing from that list.

  3. Open the session URL in the page with the library instead of navigating. This is a sketch from Polar's docs, not code from useDeploy, and we have not run it:

    import { PolarEmbedCheckout } from '@polar-sh/checkout/embed';
    
    const checkout = await PolarEmbedCheckout.create(session.url);

Everything else stays: the server-side session, the metadata, and above all the webhook that fulfils the order.

Common errors

SymptomCause
Checkout creation fails right awayA sandbox token with environment: 'production', or the reverse.
An error about unknown productsProduct IDs from the other environment (sandbox vs production).
Success page shows {CHECKOUT_ID} literallyThe URL was built with a template that escaped the braces.
Customer paid but has no accessAccess is granted on the webhook, and the webhook is failing.
Embedded overlay blankembed_origin or the Settings allow-list does not match the page origin.

Frequently asked questions

Does Polar support embedded checkout?

Yes, through a script tag or the @polar-sh/checkout library, with an embed_origin on the session and your domains allow-listed in Polar's settings.

Does useDeploy use Polar's embedded checkout?

No. It creates a hosted checkout session on the server and redirects the browser to it. The adapter never sets embed_origin.

Can I use a Checkout Link instead of creating sessions?

Yes, for a fixed product with no per-user data. Sessions let you set external_customer_id and metadata per customer, which is what ties a payment to your user.

How do I test without real money?

Use Polar's sandbox: a separate organization, products and access token, and environment: 'sandbox' in the client. In useDeploy that is POLAR_SERVER=sandbox, the default outside production.

Which Polar SDK version do the snippets use, and what changed from 0.x?

@polar-sh/sdk 1.0.2, imported from @polar-sh/sdk/2026-10. Compared with 0.47.x the client is built with createPolar({ accessToken, environment }) instead of new Polar({ accessToken, server }), and request fields are snake_case (external_customer_id, success_url, return_url, embed_origin) instead of camelCase. Check your pinned version before copying an import.


useDeploy ships Polar billing with hosted checkout, the customer portal, signed webhook handling and plan sync behind a provider-agnostic port. See the Polar integration and the billing docs.