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.
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 ascustomer.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
| Hosted | Embedded | |
|---|---|---|
| Customer experience | Leaves your site, returns to success_url | Overlay on your page |
| Extra setup | None | embed_origin, allowed domains in Polar settings, HTTPS, @polar-sh/checkout |
| Apple Pay and Google Pay | Work without extra setup | Disabled by default; Polar enables them on request per domain |
| Failure mode | Redirect fails visibly | An iframe blocked by frame-ancestors if the domain is not allow-listed |
| Code on the client | window.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:
-
Create the session with the origin of the page that will host the iframe.
embed_originexists on the checkout create input in@polar-sh/sdk1.0.2:const checkout = await client.checkouts.create({ products: [...], embed_origin: 'https://app.example.com', // ...the same fields as above }); -
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-ancestorsmeans the domain is missing from that list. -
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
| Symptom | Cause |
|---|---|
| Checkout creation fails right away | A sandbox token with environment: 'production', or the reverse. |
| An error about unknown products | Product IDs from the other environment (sandbox vs production). |
Success page shows {CHECKOUT_ID} literally | The URL was built with a template that escaped the braces. |
| Customer paid but has no access | Access is granted on the webhook, and the webhook is failing. |
| Embedded overlay blank | embed_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.