Skip to main content
This how-to is for developers adding per-user software as a service (SaaS) billing to a TypeScript Next.js application. It uses the App Router and assumes the application already uses Better Auth for authentication. Your goal is to create a server-owned Polar checkout and authorize paid access from Customer State. Polar’s externalId stores your application’s user identifier (ID) on the Polar customer. This page implements one billing model: one Better Auth user maps to one Polar customer. Do not apply it unchanged to organizations, teams, or seat-based billing. The request path has four responsibilities:
  • Better Auth: authenticates the request and supplies the verified user ID
  • Your server: creates or associates the Polar customer and fixes checkout policy
  • Polar Customer State: returns active subscriptions and granted benefits
  • Your authorization code: checks one stable benefit ID before protected work runs

Use the supported package set

This how-to pins the software development kit (SDK) and adapter versions that its imports target. The clean validation path also pins the framework and TypeScript toolchain.
1

Create a clean validation application

Create a TypeScript App Router application with create-next-app 16.2.10. This exact version is available in the versioned Next.js package metadata. Skip this step when you are adding the integration to an existing application.
2

Install the pinned integration dependencies

Install the exact versions used by every snippet on this page. The clean harness also pins React and TypeScript so another reader can reproduce the type check.
Adapter 1.8.4 declares @polar-sh/sdk ^0.47.0 and Better Auth ^1.4.12 in its release manifest. Keep the SDK on 0.47.x while you use this adapter version. The adapter 1.8.4 release includes the update to SDK 0.47.0.

Choose lazy customer creation before configuring Better Auth

This how-to sets createCustomerOnSignUp: false and creates the Polar customer on the first billing action. That policy keeps Polar availability out of signup and makes recovery explicit. Adapter 1.8.4 uses two eager hooks when createCustomerOnSignUp is true. It creates or finds a customer before local user creation, then assigns externalId after local creation. See the pinned customer hooks. The same adapter also registers a user-deletion hook in the pinned server integration. With eager creation enabled, that hook deletes the matching Polar customer after local deletion. Polar customer deletion immediately cancels active subscriptions, revokes benefits, and clears the external ID. Polar anonymizes personally identifiable information only when deletion uses anonymize=true. Because this page uses lazy creation, the adapter does not perform that automatic customer deletion. Define your account-deletion policy separately before adding remote deletion to your application.

Prepare Polar and the application

Complete these prerequisites before you add application code. Use separate Polar sandbox and production resources so IDs cannot cross environments.
1

Create the product and access benefit

Create a Polar product for the paid plan. Then create a Feature Flag benefit, which grants application feature access, and attach it to that product.
2

Create a scoped organization token

Create a Polar Organization Access Token, which authenticates your server to the Polar application programming interface (API). Grant only the scopes used by this page:
  • customers:read
  • customers:write
  • checkouts:read
  • checkouts:write
  • customer_sessions:write
3

Protect signup before billing integration

Keep email verification, signup rate limits, and your bot challenge enabled. Lazy creation prevents Polar calls during signup, but these controls still protect account creation.
4

Add local environment variables

Set an explicit billing environment. Preview and staging deployments can set NODE_ENV=production, so do not use NODE_ENV as the Polar environment selector.
Use your application’s public uniform resource locator (URL) as APP_URL. The webhook secret is absent because this how-to does not configure webhooks.

Validate configuration at startup

Validate billing configuration once so route code can use typed values. The shared UUID schema also validates checkout identifiers later in the flow.
Parse the environment and require APP_URL to be an origin without a path or query.

Configure Better Auth and Polar

Create one Polar client for server code. Global retries stay disabled so write requests are never replayed automatically.
Configure Better Auth with lazy Polar customer creation. Adapter 1.8.4 parses the portal return URL during initialization, so pass an absolute URL. The behavior is visible in the pinned portal source.
Mount the Better Auth handler at its documented Next.js App Router path. This mount also serves the Polar portal endpoint added by the adapter.
For a new SQLite-backed Better Auth application, create the required tables before starting the development server. Use the Better Auth migration command.

Create or associate the customer on the first billing action

The first server-owned billing action must ensure that the signed-in user has one Polar customer. The helper below also recovers a customer that exists by email but lacks an external ID. Import the generated error classes from their model paths. These paths target the exact SDK 0.47.1 package, so re-run the validation harness before changing the SDK.
Associate an email-matched customer only when its external ID is empty or already matches the local user. Polar requires each external ID to be unique and prevents changing it after assignment.
Create a customer with the local user ID when no safe email match exists. A validation error triggers one lookup retry to handle a concurrent create request.

Add a concrete checkout rate limiter

The local harness uses a fixed-window in-memory limiter so the route is runnable without another service. Replace this file with a shared-store implementation before deploying more than one application process.

Create checkout from a server-owned route

Keep price-sensitive fields on the server. The browser sends only the plan key, while the server sets the product, discount policy, trial policy, and redirect URLs.
The route enforces four server-side controls before it creates a checkout:
  • verifies the request origin for cross-site request forgery (CSRF) protection
  • verifies the Better Auth session
  • accepts only the pro plan key
  • rate limits each authenticated user
It returns JavaScript Object Notation (JSON) containing only a server-created checkout URL.
Finish the same route by applying the limiter and handling Polar failures. The handler discards every client field except plan.
Add a client button that prevents duplicate browser submissions while checkout starts. Server validation remains the authorization and pricing boundary.
Handle the response and send the browser only to the server-provided checkout URL.
Render the disabled state and an accessible error message.

Read Customer State and authorize on the server

Customer State returns the current subscriptions and granted benefits for one customer. Treat only a real 404 as “no Polar customer”; keep validation, rate-limit, timeout, and server failures distinct.
Authorize the stable benefit ID that you attached to the paid product. Product names and benefit metadata can change without changing this identifier.
A protected server route must derive the user ID from the verified session. Return 503 when Polar cannot answer instead of converting an availability failure into a denied entitlement.

Route checkout completion from verified state

The success URL does not grant access. The application must verify the checkout identifier, the signed-in user, checkout ownership, checkout status, and Customer State. The status endpoint returns one of four explicit results: Define the response type next to the resolver so the browser and server share one contract.
Route each checkout status explicitly. Clicking Pay can produce confirmed, but only succeeded confirms checkout completion.
Validate the universally unique identifier (UUID) before calling Polar. Return 404 for a missing checkout or an ownership mismatch, and 503 for other Polar failures.
Resolve the status without exposing another user’s checkout or accepting browser-provided destination URLs.

Add a bounded finishing page

The finishing page polls only while the status is pending. It stops after 30s and lets the customer retry or return to billing settings.
Define the response contract again in the client file so readers do not need to search backward for its shape.
Poll with bounded exponential backoff. The browser follows only destinations returned by the server-owned status route.
Run the poll when the component mounts or the customer clicks Try again.
Render a finite recovery path instead of an endless loading state.

Add the customer portal client

The portal plugin adds authenticated customer methods to the Better Auth client. This client lets a customer open Polar’s hosted billing portal after a Polar customer exists.
Call the portal method from an authenticated client component after Customer State exists. Hide this button before the first billing action creates or associates the Polar customer.
Webhooks are not required for this implementation because every authorization decision reads Customer State directly. Add Polar webhooks only when another feature requires asynchronous delivery.

Validate the integration

The pinned setup at the start of this page is the local type-check harness. Copy every snippet into its documented file path, add the environment variables, and add this script to package.json.
Before publishing or deploying the integration, verify this path end to end:
  • Install the pinned Better Auth, adapter, and SDK versions from a clean lockfile
  • Run the Better Auth database migration and start the Next.js application
  • Sign in, start checkout, and verify the server ignores extra browser fields
  • Confirm a new billing action creates one customer with the local user ID as externalId
  • Confirm an email-matched customer with no external ID becomes associated once
  • Reject a customer whose existing external ID belongs to another local user
  • Return 401 without a valid session and 429 after five checkout attempts per minute
  • Reject malformed checkout IDs and another user’s checkout with 404
  • Route open, confirmed, succeeded, failed, and expired checkout states as documented
  • Grant protected access only after Customer State contains POLAR_PRO_BENEFIT_ID
  • Return 503 when Polar cannot provide the entitlement decision
  • Verify the 30s finishing flow stops polling and offers a retry path
  • Run npm run typecheck with every published TypeScript and TSX snippet in place