← All articles

Billing / Next.js / Polar

Next.js SaaS Billing with Polar

Connect Polar checkout to paid access in your Next.js SaaS. A practical guide to customer IDs, webhooks, subscription state, and server-side feature gates.

11 min read
A payment card, a verification chamber, and an open access gate connected by a blue signal on a dark technical drawing.
On this page

A customer finishes checkout, returns to your app, and clicks a paid feature. Whether that feature works depends on more than the payment page: the purchase has to belong to the right account, the server has to read its billing state, and the operation has to enforce the right permission.

This guide to Next.js SaaS billing with Polar follows that connection. We use the architecture already in Motoko Base: Polar stores the subscription state; the application maps it to plans and feature access. The examples cover account-level recurring subscriptions, not a complete implementation of organization billing, usage-based metering, or one-time purchases.

If you are still preparing the rest of your release, start with the Next.js SaaS launch checklist.

Separate the checkout journey from the access decision

The browser takes the customer through a purchase. Your server decides whether a particular operation is allowed. Treat those as two connected paths with different responsibilities.

Two paths, two responsibilities
01 / Customer experienceUpdates what the customer sees
  1. Your app
  2. Polar Checkout
  3. Return page
02 / Access decisionDecides what the customer can do
  1. Customer State
  2. Your plan rules
  3. Paid operation
A return URL is a navigation event. Verified provider state, evaluated on your server, is the input to an access decision. Webhooks tell the app when that state may have changed.

In Motoko Base, the provider calls customers.getStateExternal() with the authenticated user's ID. The result becomes a smaller BillingSummary used by the dashboard and entitlement helpers. There is no local subscription mirror in PostgreSQL.

Polar's Customer State documentation describes the aggregated subscription and benefit state available through its API. Keeping that state at the provider reduces the synchronization logic in this starter. It also means you must make an explicit choice about caching, provider failures, and how quickly an access change needs to take effect.

01. Configure one subscription product in the sandbox

Begin with one paid plan and one billing interval. Add the other combinations after the complete path works. Each configured combination should map to the intended product in the same Polar environment.

For Motoko Base, a minimal sandbox configuration looks like this. The values below are placeholders, not credentials:

Server-side billing configuration
BILLING_PROVIDER=polar
POLAR_SERVER=sandbox
POLAR_ACCESS_TOKEN=your-sandbox-organization-token
POLAR_WEBHOOK_SECRET=your-sandbox-endpoint-signing-secret
POLAR_PRO_MONTHLY_PRODUCT_ID=your-sandbox-recurring-product-id

Create the token, recurring product, and webhook endpoint in the sandbox. Point the endpoint at your deployed test app's /api/billing/webhooks/polar route. For local development, use a tunnel you control and register its webhook URL.

Polar's sandbox is separate from production, including its credentials. Keep the environment, token, and product IDs together when switching configurations. A sandbox product ID should not be mixed with a live token.

Motoko Base resolves a plan and billing interval to a product ID on the server. The browser can request an allowed plan, but it should not choose an arbitrary provider product or submit the price you charge. Configure the allowed combinations in the product mapping and validate the request before starting checkout.

Use the payments setup guide and environment-variable reference for the project's full configuration. Keep tokens and signing secrets server-side.

02. Attach checkout to the authenticated account

Choose the identity that owns the subscription before creating checkout. In the current Motoko Base model, that identity is the Better Auth user ID. The provider passes it as externalCustomerId, then uses the corresponding external ID to retrieve Customer State.

For a team-based product, the billing owner may instead be an organization. That requires an organization-level model and permission checks; replacing one ID in a checkout request does not implement team billing by itself.

Here is a small Server Action that starts a fixed Pro monthly checkout through the existing Motoko Base API:

start-pro-checkout.ts
"use server"

import { getCachedSession } from "@/lib/auth/session"
import { checkout } from "@/lib/billing"

export async function startProCheckout() {
  const session = await getCachedSession()

  if (!session?.user?.id || !session.user.emailVerified) {
    return { ok: false as const, error: "Sign in with a verified account." }
  }

  return checkout(session.user.id, "pro", "monthly")
}

The application can navigate to result.url when the result is successful and show result.error otherwise. The existing billing actions also include validation, localized errors, and analytics; keep those behaviors when extending the starter.

The important boundary is the user ID. It comes from the server session, not a hidden form field, search parameter, or client-side user object. Use the authentication setup to establish that session before invoking billing helpers.

03. Read the subscription state after the customer returns

A return page should confirm what the server knows about the purchase. A URL such as ?checkout=success is not permission to enable a feature: anyone can open that URL manually.

Motoko Base's billing settings page uses getFreshBillingSummary() on its checkout-success path. This bypasses the application's usual summary cache and asks the provider for the account's current state. The normal billing summary path uses a 60-second cache revalidation setting to reduce repeated provider reads.

There can still be a handoff delay. If the browser returns before the expected subscription appears, show a processing message and a way to check again. Preserve access according to verified state; do not encourage the customer to purchase a second time just to refresh the screen.

What the server knowsUseful customer-facing response
A recognized active subscription is presentShow the mapped plan and its available features
The expected change is not yet visibleExplain that confirmation is still processing and offer a retry
The provider lookup failedShow a temporary billing-status error without claiming that payment failed
The configured product cannot be mappedInvestigate the product mapping instead of guessing a paid plan

A checkout ID can help you investigate a purchase, but looking one up still requires an ownership check. Do not expose another customer's checkout details because the caller supplied its ID.

04. Verify webhooks and refresh the right billing state

Register events relevant to your integration. Polar exposes customer.state_changed for aggregated customer changes and subscription events for more specific lifecycle handling. Its event reference explains their meanings and cancellation sequences.

The existing Motoko Base webhook route reads the raw request body and passes it, with the request headers, to the provider handler. That handler validates the signature before using the payload. Follow Polar's current signature-validation documentation for the exact SDK and signing scheme you use; do not combine snippets from different SDK generations.

After validation, the handler extracts the linked external user ID, marks that user's billing cache stale, and revalidates the billing and usage pages. It does not write a subscription row into the application database.

There are two details worth making explicit:

  • Cache invalidation is not a synchronous refresh. The current handler uses stale-while-revalidate behavior. A subsequent request may receive the cached value while a new value is fetched. The checkout-success page's fresh read addresses a different part of that experience.
  • This handler is not a durable fulfillment queue. Its cache and analytics side effects are best effort. If you add business-critical work such as issuing credits or provisioning an external resource, persist or enqueue that work durably, make it idempotent, and design its retry behavior separately.

Use the provider's delivery history to inspect failures and redeliver events during a rehearsal. Avoid recording access tokens, signing secrets, or complete customer payloads in general-purpose logs.

05. Enforce feature access inside the server operation

An upgrade button and a locked dashboard card explain the product. They do not protect the work behind a Server Action or Route Handler. Check the account's entitlement at the boundary where that work executes.

Motoko Base's hasFeature() helper maps a billing summary to a feature in the plan catalog. For example, the current catalog enables ai_email on its paid plans. A guard for an operation using that feature can look like this:

require-ai-email-access.ts
import "server-only"

import { getCachedSession } from "@/lib/auth/session"
import { hasFeature } from "@/lib/billing"

export async function requireAiEmailAccess() {
  const session = await getCachedSession()

  if (!session?.user?.id || !session.user.emailVerified) {
    throw new Error("A verified session is required.")
  }

  if (!(await hasFeature(session.user.id, "ai_email"))) {
    throw new Error("Your current plan does not include this feature.")
  }

  return session.user.id
}

Call the guard from the operation that performs the paid work. Then validate the operation's input and enforce ownership of any referenced records. Paid access does not authorize a customer to edit someone else's data.

This guard uses the normal cached billing path. For an operation that requires an immediate revocation decision, evaluate whether that cache policy is sufficient. The starter also exposes getFreshBillingSummary() and summaryHasFeature() for an explicit fresh-state check; decide how the operation should behave if the provider is unavailable. A temporary lookup failure should not silently grant paid access.

The ai_email name is a catalog example. Map your product's actual capabilities and add the corresponding guards. A starter's demo navigation does not establish that every demo operation is gated for your business.

06. Treat cancellation as an access policy

A cancellation request and the end of access are not necessarily the same moment. Polar's subscription event reference distinguishes cancellation at the end of a billing period from immediate revocation.

In the current Motoko Base mapping, a subscription scheduled to cancel is represented as canceled with cancelAtPeriodEnd: true. The paid-access helpers continue to allow it while it remains in the provider's active state. Once the relevant subscription is no longer active in Customer State, it no longer supplies that paid access.

Test the behavior you promise to customers:

ScenarioWhat to verify
Customer cancels at period endAccess remains for the intended remaining period
The subscription is revokedPaid operations stop after state and caches update
A renewal payment failsThe app follows your chosen recovery policy and gives the customer a next step
A cancellation is reversedThe displayed renewal state and access agree with the provider
The same webhook is delivered againNo duplicate credit, order, or provisioning side effect is created

The starter's current paid-access helper does not grant access to a past_due summary. If you offer a grace period, implement and test that policy explicitly rather than assuming it is already part of the integration.

Let customers manage supported billing changes through an authenticated customer-portal session. Motoko Base exposes openPortal(userId); derive that ID from the session just as you do for checkout. The portal's return page should read the resulting state, rather than assume what the customer changed.

Rehearse the complete billing flow

Before switching to live billing, walk through these cases on a deployed test app:

  1. Free account: a direct call to the paid operation is rejected.
  2. Successful purchase: the correct account gets the configured plan and can perform the operation.
  3. Second account: another user cannot open the purchaser's billing session or use its entitlements.
  4. Return before confirmation: the UI offers a status refresh without initiating another purchase.
  5. Invalid webhook: signature validation rejects the payload before side effects run.
  6. Repeated delivery: any added fulfillment work remains safe to repeat.
  7. Cancellation and revocation: access follows the policy you wrote down, including cache behavior.
  8. Provider failure: the app gives a useful error and does not invent a successful purchase or grant access.

Use Polar's documented sandbox payment methods for the purchase rehearsal. When moving to production, configure the live token, live products, and live webhook endpoint together, then verify the deployed settings.

How this fits the rest of your SaaS

Billing sits between identity, product permissions, and the experience your customer pays for. Keeping those responsibilities explicit makes changes easier to reason about: the provider records the subscription, your plan catalog describes the product, and the server operation enforces the permission.

Motoko Base supplies those integration points. Your product still needs its own entitlement rules, failure handling, and release rehearsal. The launch checklist connects billing checks with onboarding, email, data ownership, and deployment; the production setup guide covers the project configuration.

Questions that come up during implementation

Do I need to copy every subscription into my own database?

The architecture in this article does not. It reads provider state and caches a small application summary. A local projection can be useful when your product needs different availability or query guarantees, but it also needs synchronization, reconciliation, and a defined source of truth.

Can a successful checkout return URL unlock a feature?

The return URL can prompt a fresh lookup. The access decision must still use trusted server-side billing state for the authenticated account.

Does this implement team subscriptions or prepaid credits?

No. Team billing changes which entity owns the subscription and who may manage it. Credits add a durable balance and fulfillment rules. Design those explicitly instead of treating them as a small variation of a per-user subscription check.