Skip to main content
This how-to adds per-user software as a service (SaaS) subscription billing to a TypeScript Next.js App Router application that already uses Better Auth. It creates checkout on the server and authorizes paid access from Polar Customer State. The identity invariant is one Better Auth user to one Polar customer. Polar’s externalId stores the immutable application user identifier (ID) on that customer. Email is used only for a verified, one-time recovery association when an existing Polar customer has no external ID. Do not apply this model unchanged to organizations, teams, shared accounts, or seat-based billing. The request path has four responsibilities:
  • Better Auth: authenticates the request and proves email ownership before any email-based customer association.
  • Your server: creates or associates the Polar customer and sets the allowed product, discounts, trials, customer IP address, and redirect URLs.
  • Polar Customer State: returns the current customer and granted benefits.
  • Your authorization code: checks one stable benefit ID before protected work runs.
The implementation follows this order:
  1. Pin the integration and local validation environment.
  2. Define the customer lifecycle and recurring product.
  3. Validate environment configuration.
  4. Add the Polar plugin to the existing Better Auth configuration.
  5. Create or safely associate a verified customer’s Polar record.
  6. Add bounded local rate limits and Customer State caching.
  7. Create checkout from a server-owned route.
  8. Authorize protected work from Customer State.
  9. Resolve checkout completion from verified server state.
  10. Add abortable, bounded client polling and portal recovery.
  11. Run build and sandbox validation before deployment.

Use the supported package set

The integration examples target exact Better Auth, Polar adapter, and Polar SDK versions. The optional validation harness is a disposable runnable application, not a production database or a replacement for your existing auth setup. For an existing application, keep its current Better Auth installation in place. The snippets on this page were validated with Better Auth 1.6.23; do not silently replace a working auth version just to follow the billing setup. Install the missing integration dependencies with exact versions:
If npm ls reports a Better Auth version other than 1.6.23, confirm adapter compatibility and run the full validation path before deployment. Skip framework, React, database, and TypeScript replacement when your application already supplies them. For the clean validation harness, use Node.js 22.19.0 with npm 10.9.3. Next.js 16 requires Node.js 20.9 or newer, and this harness keeps the runtime and Node type declarations on the same major version.
1

Create the validation application

Create a TypeScript App Router application with create-next-app 16.2.10. The executable package version is available in the create-next-app registry metadata.
2

Pin the harness runtime and package manager

Record the exact local runtime, then make npm save exact dependency versions.
3

Install harness-only dependencies

Install the framework, integration, SQLite, and matching type packages used by the runnable example.
Adapter 1.8.4 declares @polar-sh/sdk ^0.47.0 and Better Auth ^1.4.12 in its pinned release manifest. Keep the SDK on 0.47.x while using this adapter version. Also keep the canonical Better Auth Polar plugin documentation for supported usage and the pinned source only for version-specific behavior.

Choose the customer lifecycle before configuring Better Auth

This guide uses createCustomerOnSignUp: false so signup does not depend on Polar availability. Lazy creation also means the adapter’s customer create, profile synchronization, and delete hooks are not your lifecycle policy. With eager creation enabled, adapter 1.8.4 runs customer hooks around Better Auth user operations. Those hooks are best-effort synchronization: they depend on Better Auth operation context, and remote deletion can fail after the local user has already been deleted. See the pinned customer hooks. Define these policies before deploying lazy creation:
  • Email and name changes: update the Polar customer by its existing external ID after the local change is verified. Do not reassign ownership by email.
  • Account deletion: decide whether to retain, delete, or anonymize the Polar customer. Deleting a customer can cancel subscriptions and revoke benefits; use the versioned Delete Customer API only when that matches your product policy.
  • Account recreation: a new local user ID is a new identity. Do not let a new account take over a Polar customer whose external ID points to the deleted ID. Reconcile the record through an authenticated support or administrative flow.
  • Account merge: choose the surviving local user ID first, then reconcile the Polar customer deliberately. Never infer a merge only from matching email.

Prepare Polar and the application

Create a recurring subscription product for the paid plan, not a one-time purchase. A Feature Flag benefit attached to a recurring product follows the subscription lifecycle, while a one-time purchase can grant the benefit permanently. See Feature Flag benefits.
1

Create the recurring product and access benefit

Create the recurring Polar product, then attach one Feature Flag benefit for the application capability this guide calls Pro access. Copy both IDs.
2

Create a scoped organization token

Create a Polar Organization Access Token and grant only the scopes used here:
  • customers:read
  • customers:write
  • checkouts:read
  • checkouts:write
  • customer_sessions:write
3

Keep verified-email signup controls enabled

The email recovery association later in this guide is safe only after the application has proved ownership of the session email. Keep signup rate limits, bot controls, and email verification enabled.For email and password, configure emailVerification.sendVerificationEmail and emailAndPassword.requireEmailVerification: true. For social sign-in, enable the provider-specific requireEmailVerification option only for providers that report a trustworthy verification claim. Otherwise, require a separate verified email step before billing.
Generate a high-entropy Better Auth secret instead of copying a sample value. For the pinned version in this guide, run:
Use one canonical application origin. This guide passes APP_URL directly to Better Auth instead of maintaining a second BETTER_AUTH_URL value.
CUSTOMER_IP_HEADER is empty in the local sandbox. In production, set it to the header your trusted ingress overwrites with the connecting customer IP. Do not trust a client-controlled forwarded header. Polar uses the request IP for country, currency, billing-country prefilling, and preliminary tax behavior when checkout is created from a backend.

Validate configuration at startup

Validate configuration once so route code uses typed values. The shared universally unique identifier (UUID) schema also validates checkout IDs later. Production requires HTTPS and a configured trusted customer-IP header.
Parse the origin and apply production-only requirements explicitly.
Export normalized values and turn an empty sandbox IP header into undefined.

Configure Better Auth and Polar

Add the Polar plugin to your existing Better Auth configuration without replacing its database adapter, auth methods, session policy, trusted origins, providers, or hooks. The full SQLite configuration below exists only for the clean harness. Create one Polar client for server code. Global retries remain disabled so write requests are never replayed automatically.
Define the Polar billing plugin separately so an existing app can append the same plugin to its current plugins array.
In your existing application, add polarBilling to the existing plugins array and keep the rest of your auth configuration unchanged. Also ensure every auth method that can reach billing produces a verified local email before association. The clean harness uses SQLite and logs verification links only in sandbox. Never use this database or console delivery as a production starting point.
Mount the Better Auth handler at its documented App Router path. This mount also serves the Polar portal endpoint added by the adapter.
For the clean harness only, create the SQLite schema with the CLI version that matches the deployed Better Auth version. Do not use a mutable latest CLI for a schema migration.
auth.db is local-only. A relative SQLite file is not suitable for typical serverless, ephemeral, or multi-process deployment, and it can contain user and session data after testing. Production must keep the application’s existing production-grade database configuration.

Create or associate the customer on the first billing action

The first billing action must ensure the verified signed-in user maps to exactly one Polar customer. Email recovery is allowed only when the local email is verified and the matching Polar customer has no external ID. Import the generated SDK error classes from their pinned model paths. These deep imports target SDK 0.47.1, so re-run validation before changing the SDK.
Reject unverified identities inside the helper as defense in depth, even though billing routes also reject them before calling this code.
Associate an email-matched customer only when exactly one match exists and its external ID is empty or already matches the local user. Polar external IDs are unique and should be treated as immutable ownership identifiers.
Create a customer only after the verified email lookup fails. A 422 does not become success by itself: the helper performs one read-after-write recovery lookup and rethrows the original validation error unless that lookup proves the customer is now safely owned by this user. This bounds the concurrency recovery path without treating arbitrary invalid data as a successful create.
After initial association, synchronize verified profile changes by external ID in your own account-update flow. Do not use this email recovery helper as a general profile synchronization mechanism.

Add bounded local API controls

The harness uses process-local controls so it can run without another service. Production with multiple processes must replace them with shared implementations. A distributed rate-limit consume/reset operation must be atomic; a remote read-then-write sequence is not sufficient. Use one bounded fixed-window helper for checkout, status polling, and protected feature routes. It removes expired entries during periodic sweeps and returns a retry delay for Retry-After.
Customer State is latency-sensitive and authorization-sensitive. This harness uses a three-second per-user cache to reduce repeated Polar calls. That creates up to three seconds of entitlement staleness, so choose a shorter, longer, or zero TTL according to your revocation requirements.
Bound the local cache instead of allowing it to grow with every distinct user.
The SDK timeout, short cache, and per-user route limits bound local latency and API consumption. Higher-volume production systems should use a shared cache, organization-wide budgets, and a circuit breaker so a Polar outage does not turn all application traffic into synchronized upstream calls.

Create checkout from a server-owned route

Keep price-sensitive fields on the server. The browser sends only the plan key; the server sets the product, discount policy, trial policy, customer identity, customer IP address, and redirect URLs. Define application-specific route constants once so examples cannot drift. Replace these values when your application uses different paths.
Read the customer IP only from a header guaranteed by your deployment ingress. For comma-separated trusted forwarding headers, this example uses the first address after the trusted proxy has overwritten client input.
Pass that IP into Polar checkout creation. Polar documents this requirement for backend and Next.js route-handler checkout creation in its Checkout API guide.
Finish the checkout policy with server-owned discount, trial, and redirect settings.
Classify durable Polar configuration failures separately from transient upstream failures while keeping public error messages generic.
The checkout route enforces same-origin POST requests, a verified session, one plan key, and a per-user rate limit before creating checkout.
Apply the rate limit and include Retry-After when it is exceeded.
Handle ownership conflicts, permanent configuration defects, and transient Polar failures separately in logs and status codes.
The dynamic imports above keep each teaching block short. In application code, normal top-level imports are preferable. Add a client button that always resets its disabled state on non-navigation exits, including network failure and invalid JSON.
Validate the response shape before navigation and catch all non-navigation failures.
Render disabled and accessible error states.

Read Customer State and authorize on the server

Customer State is the authorization source. Only ResourceNotFound from the Customer State request means that no Polar customer exists; validation, timeout, rate-limit, and server failures must propagate as availability failures. Authorize the stable benefit ID attached to the recurring product.
A state-changing protected POST route applies the same Origin check as checkout, then rate limits each authenticated user before making a Customer State request.
Fail closed on authorization while distinguishing Polar unavailability from a genuine missing entitlement.

Route checkout completion from verified state

The success URL never grants access. The server verifies the checkout ID, signed-in user, checkout ownership, checkout status, and Customer State before the browser can proceed. Define one client-safe runtime contract instead of duplicating a TypeScript type. There are five distinct response states: pending, ready, resume, failed, and support.
Resolve ownership before interpreting checkout state. The current versioned Polar reference is Get Checkout Session.
Route open, confirmed, failed, and expired checkouts explicitly. confirmed only means the customer clicked Pay; it does not prove payment success.
A succeeded checkout gets a bounded grace period for Customer State propagation. After the grace period, missing the configured benefit becomes an actionable support/configuration state instead of pending forever.
The status endpoint validates the UUID before Polar, rate limits each user, masks ownership mismatches as 404, and returns 400 for malformed IDs.
Validate and resolve the request without exposing another user’s checkout.

Add an abortable finishing page

The browser polls only while the server returns pending. Every attempt has a strict 30-second deadline, each fetch has an abort signal, cleanup cancels the active loop, and retry cannot start a second concurrent loop. The server component passes the checkout ID to the client component.
Use one abortable delay helper.
Poll with bounded exponential backoff. The remaining deadline limits both the fetch timeout and the final sleep, so the attempt does not run beyond 30 seconds.
Bound the delay by the remaining deadline.
Each effect owns one AbortController. Cleanup cancels the request and any sleep, including React development Strict Mode cleanup. The retry button is disabled while an attempt is active.
Ignore expected aborts, surface other failures, and reset the polling state only for the currently active attempt.
Render a finite recovery path. Clicking retry resets the message before starting the next, non-overlapping attempt.

Add the customer portal and runnable harness pages

The Polar portal should be offered only after Customer State proves a Polar customer exists. The client must also surface portal failures instead of discarding them. Create the Better Auth client once for sign-in, signup, and Polar portal methods.
Handle portal loading and errors in the client component.
Render the disabled state and an accessible portal error.
The billing settings page renders Upgrade for users without Pro access and hides Manage billing until a Polar customer exists.
The protected application page checks Customer State server-side rather than trusting the checkout redirect.
For the clean harness, add minimal signup and sign-in controls so the validation path is runnable. The sandbox auth configuration prints verification links to the server console; production must use a transactional email provider.
Render the signup fields.
Add a sign-in form that redirects only after Better Auth returns a session.
Render the sign-in fields.
Render both controls from /sign-in.
Webhooks are not required for this authorization model because every entitlement decision reads Customer State. Add Polar webhooks when another feature needs asynchronous delivery, analytics, or local denormalized state.

Validate the integration

The clean harness is a runnable integration fixture, not proof that payment and security behavior is correct in your deployment. Run TypeScript checking, a full Next.js build, and sandbox tests that exercise auth, ownership, failure, and cancellation paths. Add a type-check script and run both static checks.
Before publishing or deploying, verify this path end to end:
  • Use Node.js 22.19.0, npm 10.9.3, and the exact dependency versions from a clean lockfile.
  • Run the pinned Better Auth migration and confirm auth.db remains ignored and local-only.
  • Sign up with email/password and confirm no authenticated session can use billing until the email is verified.
  • For every enabled social provider, confirm the provider-specific verified email policy before allowing billing association.
  • Start checkout and confirm extra browser fields cannot change product, discount, trial, redirect, customer identity, or customer IP policy.
  • Confirm checkout receives the trusted customer IP in production-like proxy tests and rejects missing or malformed trusted ingress data.
  • Confirm a new billing action creates one Polar customer with the local user ID as externalId.
  • Confirm a verified email-matched customer with no external ID associates once, while an unverified account cannot claim it.
  • Reject a Polar customer whose external ID belongs to another local user.
  • Verify local email/name changes use your explicit external-ID sync policy.
  • Verify account deletion, recreation, and merge behavior matches the written lifecycle policy and never reclaims ownership by email alone.
  • Reject cross-origin POST requests on checkout and every protected state-changing route.
  • Return 401 without a valid session, 403 for unverified billing identity, and 429 with Retry-After after the documented route limits.
  • Confirm shared production rate limits use atomic consume/reset semantics and the production cache/circuit-breaker policy bounds organization API pressure.
  • Return 400 for malformed checkout IDs and 404 for missing or another user’s checkout.
  • Route open, confirmed, succeeded, failed, and expired checkout states as documented.
  • Grant access only when Customer State contains POLAR_PRO_BENEFIT_ID.
  • After a succeeded checkout, confirm missing benefits change from bounded pending to the support/configuration state instead of polling forever.
  • Return 503 when Polar cannot make an entitlement decision and distinguish durable 401, 403, or 422 upstream configuration failures in server logs.
  • Unmount and remount the finishing page under React development Strict Mode and confirm old fetches and sleeps are aborted.
  • Confirm Try again is disabled during polling and cannot create overlapping attempts.
  • Simulate network failure, fetch abort, proxy HTML, and invalid JSON; confirm checkout and polling controls recover with accessible errors.
  • Confirm the finishing attempt stops within 30 seconds, including in-flight fetch and delay time.
  • Hide Manage billing before a Polar customer exists and surface portal failures with a retryable client state.
  • Run npm run typecheck and npm run build with every published TypeScript and TSX snippet in place.