Skip to content

Payments

Next.js boilerplate with Polar

Merchant of record for developers. Polar sells to your customer, so tax is its job.

Subscriptions and one-time payments on Polar, the merchant of record: hosted checkout, the customer portal, refunds that revoke access, and a signature-verified webhook with delivery-id idempotency. Plans live in src/lib/pricing.ts and /pricing renders with no keys.

What Polar adds to the agent layer: 2 rules · 2 skills · 6 solution docs · 1 MCP server

Maintained by @raviMITNext.js on Vercel

From the manifest

Should you pick Polar?

Pick it if

Solo founders and small teams selling digital products or SaaS worldwide, by subscription or as a one-time lifetime deal. You skip registering for VAT, GST and US sales tax before your twentieth customer.

Watch out for

  • Polar is the seller on the customer's statement and invoice. That is the point: the tax liability moves to Polar. It also means the descriptor is not your brand, and enterprise buyers sometimes ask why.
  • Payouts arrive on Polar's schedule, after it has collected and remitted. Plan your runway on that, especially in the first month.
Show 4 more
  • The fee is higher than a direct card processor because tax is included. At high volume, compare it with a processor plus a tax vendor.
  • One product is one price. A plan sold monthly, yearly and for life is three Polar products and three env ids. billing:sync-plans creates them for you.
  • Sandbox and production are separate Polar deployments with separate tokens, products and webhook secrets. Launch day is a second setup, not a switch.
  • Fewer billing knobs than Stripe. For a plain subscription or a lifetime deal that is often enough.

What it costs

Free Starter plan at 5% + 50c per transaction. Paid plans lower the rate: Pro is $20/month at 3.8% + 40c, and Growth and Scale go lower. Non-US cards add 1.5%. Tax calculation, filing and liability are included. Organisations created before May 27, 2026 can stay on the Early Member plan: 4% + 40c, plus 0.5% on subscriptions.

Prices change. Check with Polar before you commit.

registry/tested.yaml

Tested with Polar

Each pair was installed, typechecked, linted, built and booted together.

Database
NeonSupabase
Admin panel
Admin panel
Error tracking
Sentry
Customer support
Crisp

What it adds

What Polar adds to the repo

Read straight from the polar manifest, so it is exactly what lands in your repo.

Environment variables

  • POLAR_ACCESS_TOKENRequired

    Organization access token, from Settings -> Developers in the Polar dashboard. Sandbox tokens come from sandbox.polar.sh and do not work in production. Server-side only. Until it is set, /pricing still renders and every buy button explains that billing is not set up.

    Placeholder
    polar_oat_replace_me
  • POLAR_WEBHOOK_SECRETRequired

    Signing secret for /api/webhooks/polar. Locally, bun run polar:listen prints one. In production, copy it from the endpoint you create in the dashboard. Both the older secret format and the Standard Webhooks whsec_ format verify. Sandbox and production secrets differ.

    Placeholder
    whsec_replace_me
  • POLAR_ORGANIZATION_IDRequired

    The organization id, from Settings -> General. billing:sync-plans, billing:reconcile and bun run verify scope their reads to it. Checkout does not need it: the token already belongs to one organization.

    Placeholder
    00000000-0000-0000-0000-000000000000
  • POLAR_ENVIRONMENTOptional

    "sandbox" or "production". Picks the Polar API host. Defaults to sandbox, so a fresh clone cannot take a real payment by accident.

    Placeholder
    sandbox
  • BILLING_PRICE_PRO_MONTHLYOptional

    The Polar product id (a UUID) for the catalogue price pro-monthly, a monthly recurring product. bun run billing:sync-plans creates it and prints this line. Sandbox and production have different ids.

    Placeholder
  • BILLING_PRICE_PRO_YEARLYOptional

    The Polar product id for pro-yearly, a yearly recurring product.

    Placeholder
  • BILLING_PRICE_PRO_LIFETIMEOptional

    The Polar product id for pro-lifetime, a one-time product. One payment grants the Pro plan for good; a refund or a dispute takes it away.

    Placeholder
  • BILLING_PRICE_TEAM_MONTHLYOptional

    The Polar product id for team-monthly.

    Placeholder
  • BILLING_PRICE_TEAM_YEARLYOptional

    The Polar product id for team-yearly.

    Placeholder

Dependencies

  • @polar-sh/sdk^0.49.0
  • server-only^0.0.1

Scripts

  • bun run billing:prune-events

    bun --conditions=react-server scripts/billing/prune-events.ts

  • bun run billing:reconcile

    bun --conditions=react-server scripts/billing/reconcile.ts

  • bun run billing:sync-plans

    bun --conditions=react-server scripts/billing/sync-plans.ts

  • bun run billing:test-webhook

    bun scripts/billing/test-webhook.ts

  • bun run polar:listen

    polar listen http://localhost:3000/api/webhooks/polar

MCP server

  • polar

    URL
    https://mcp.polar.sh/mcp/polar-sandbox

Files it writes

12 files, at these exact paths.

  • scripts/4 files
    • billing/4 files
      • prune-events.ts
      • reconcile.ts
      • sync-plans.ts
      • test-webhook.ts
  • src/7 files
    • app/1 file
      • api/1 file
        • webhooks/1 file
          • polar/1 file
            • route.ts
    • lib/6 files
      • billing/6 files
        • polar-objects.test.ts
        • polar-objects.ts
        • polar-webhooks.test.ts
        • polar-webhooks.ts
        • polar.ts
        • provider.ts
  • tests/1 file
    • e2e/1 file
      • billing-webhook.spec.ts

Stack slots it fills

The stack declares these injection points; this battery supplies the fragment, so the provider tree, the env check and the schema stay one file each instead of many.

  • @slot env-required
  • @slot legal-processors
  • @slot verify-checks

The differentiator

What Polar teaches your agent

Other starter kits stop at the package. This is the part an agent reads: where it may work, what it must never do there, and the problems someone already solved.

Rules (2)

Loaded when the agent opens a matching file.

Polar translates, the shared billing core writes the store

Loads onsrc/lib/pricing.tssrc/lib/billing/**scripts/billing/**.claude/rules/polar-billing-adapter.md

Billing here is one shared layer plus one adapter. This battery ships only the adapter:

FileJob
src/lib/billing/polar.tsthe one lazy Polar client; sandbox unless POLAR_ENVIRONMENT=production
src/lib/billing/provider.tsbillingProvider, the BillingProvider contract from ./types: checkout, portal, webhook verify, translate
src/lib/billing/polar-webhooks.tssignature check (both Polar secret formats) and body parsing, pure and tested
src/lib/billing/polar-objects.tspure mappers: statuses, subscription and purchase rows, receipts, product drift, tested
src/app/api/webhooks/polar/route.tsthree lines into the shared processWebhook
scripts/billing/*.tssync-plans, reconcile, prune-events

Everything else under src/lib/billing, plus /pricing, /billing and src/components/billing, is the stack's shared code and works the same for Stripe, Dodo and Lemon Squeezy. Its rule is "Billing is one shared layer with one provider adapter". Hold the line between the two:

  • The adapter never writes billing state and never sends mail. translate and syncCheckout return BillingEvent[]; applyBillingEvents is the only writer and runs them inside the idempotency claim. The one store call the adapter makes is a read: getUserIdForCustomer.
  • Nothing outside the adapter and the scripts imports @polar-sh/sdk. Not a page, not a component, not entitlements.ts. A feature that needs Polar data gets a method on BillingProvider or a field on a BillingEvent. Never a branch on billingProvider.id in shared code.
  • The client is built lazily and exists once. getPolar() builds it on first use and polar is a proxy onto it. A client built at module scope throws during next build on any deployment without keys. Never read POLAR_ACCESS_TOKEN outside polar.ts and missingConfig().
One product is one price

Polar sells one price per product. The catalogue's pro-monthly, pro-yearly and pro-lifetime are three Polar products, and each BILLING_PRICE_* env var holds a product id (a UUID):

pro-monthly   ->  BILLING_PRICE_PRO_MONTHLY=<recurring monthly product id>
pro-lifetime  ->  BILLING_PRICE_PRO_LIFETIME=<one-time product id>
  • No product id in code. Sandbox and production have different ids.
  • Amounts never come from the client. A buy button is <a href="/billing/checkout?price=pro-monthly">. The route finds the price in src/lib/pricing.ts, the core resolves the product from env, and createCheckout sends only products: [id]. Polar charges the product's own price; bun run billing:sync-plans -- --check and bun run verify fail when it differs from the catalogue.
  • The trial is the catalogue's. trialDays on a monthly or yearly price goes to checkout as trialInterval: "day". A price without it sends allowTrial: false, so a trial left on the product in the dashboard never applies by surprise.
  • price_id metadata is the second key. billing:sync-plans sets it on every product it creates. The adapter reads a product's plan from the env map first, then that metadata. A subscription never takes its plan from the checkout's metadata: a plan switch in the portal changes the product and leaves the metadata naming the first plan.
The customer is our user id

createCheckout passes externalCustomerId: user.id, so Polar links (or creates) the customer by our id, which is the same in sandbox and production. There is no createCustomer. The portal opens with customerSessions.create({ externalCustomerId }), falling back to the stored customer id. Both return URLs come from returnOrigin(), never a request header.

Scripts run with bun --conditions=react-server and may import the store, the adapter and @/lib/billing/webhook. verify runs with bun and may import only the pure files (catalog, price-refs, format, types, polar-objects) and the SDK, never polar.ts (it is server-only).

The Polar MCP server

.mcp.json points at Polar's sandbox MCP server (sign in with OAuth from /mcp). Use it to read products, orders and subscriptions while debugging. Do not create or edit products through it: billing:sync-plans does that from src/lib/pricing.ts, the same way in every environment. Its production twin is https://mcp.polar.sh/mcp/polar-mcp; reach for it only when the user asks about live data.

Verify every Polar webhook, keep every handler idempotent

Loads onsrc/app/api/webhooks/polar/**src/lib/billing/provider.tssrc/lib/billing/polar-webhooks.tssrc/lib/billing/webhook.ts.claude/rules/polar-webhook-integrity.md

The webhook route is an unauthenticated public endpoint. The signature is the only thing between a Polar event and a forged POST that grants a lifetime plan.

The route is three lines: processWebhook(billingProvider, request). The pipeline in src/lib/billing/webhook.ts is shared by every provider; the Polar parts are verifyWebhook and translate in provider.ts, built on polar-webhooks.ts. Nothing Polar-specific goes in the route.

Read the body as text. processWebhook hands the exact bytes to verifyWebhook. request.json() reorders keys and every signature fails.

Verify with verifyPolarSignature, not the SDK's validateEvent. Polar signs with Standard Webhooks headers (webhook-id, webhook-timestamp, webhook-signature), but the key depends on when the secret was made:

Secret madeHMAC key
before 8 Sep 2026the UTF-8 bytes of the whole secret
on or after 8 Sep 2026 (whsec_...)the base64 after whsec_

@polar-sh/sdk 0.49 only knows the first, so it rejects every delivery to a new endpoint. verifyPolarSignature tries both keys (as Polar's 1.0 SDKs do, so the secret polar listen prints works too), compares in constant time and refuses timestamps more than five minutes off. Keep it that way until the SDK you pin does the same. A placeholder secret throws WebhookConfigError (500, Polar keeps retrying); a bad signature throws WebhookSignatureError (400).

Only POLAR_HANDLED_EVENTS do work. Everything else answers 200 ignored and is not even parsed. Adding an event means adding it there, handling it in translate, and subscribing the endpoint to it. The endpoint format must be Raw: Discord and Slack formats sign a chat message, and the parser says so.

The claim is polar:<webhook-id>, before any side effect. The id is stable across Polar's retries of one delivery. A duplicate loses the insert and answers 200 duplicate; a throw releases the claim and answers 500 so Polar retries (up to ten times). Never write billing_processed_events by hand.

Orders are used as delivered; subscriptions are re-read.

  • order.paid / order.refunded: the signed payload is the whole order, and an order only moves forward. The purchase upsert keeps status monotonic (pending < failed < paid < partially_refunded < disputed < refunded), so a late paid never undoes a refund. One-time orders (subscriptionId null) become purchase.changed; subscription orders re-read their subscription.
  • subscription.*: only the id is taken from the payload. The subscription is fetched with polar.subscriptions.get, because its state goes back and forth and deliveries arrive out of order.

Polar's canceled is not always the end. A customer who cancels keeps access until the period ends; access ends with a revoke, which sets endedAt. normalizeSubscriptionStatus reads canceled with no endedAt and an end still ahead as active with cancelAt set, and /billing shows "Ends on" through scheduledEnd().

Mail is an event. order.paid with an amount above zero returns a payment.receipt, sent inside the claim. There is no dunning event: Polar emails the customer about a failed renewal itself. A mail failure is logged, never rethrown.

Disputes have no webhook. bun run billing:reconcile lists open and lost disputes and marks their purchases disputed. Schedule it.

Status codes are the retry protocol. 400 bad signature, 200 processed, duplicate or ignored, 500 for anything transient. Polar waits 10 seconds and disables an endpoint after 10 failures in a row, so never do slow work inline.

Skills (2)

Invoked by name.

  • /add-product

    Add or change a plan or price on Polar (monthly, yearly or one-time lifetime). Edit src/lib/pricing.ts, create the Polar product, wire its env var, and prove checkout and the webhook end to end.

    .claude/skills/add-product/SKILL.md

  • /test-webhook

    Exercise the Polar webhook endpoint locally. Forward real sandbox events with the Polar CLI, or sign a one-time order yourself; buy both kinds of price, refund one, prove replays are no-ops, and debug signature failures.

    .claude/skills/test-webhook/SKILL.md

Solution docs (6)

Written before you hit the problem. Each one ships in the repo at docs/solutions/ and is published here as a cookbook page.

Show all 6

How it fits

What Polar needs, and what it goes well with

The resolver enforces this before it generates anything, and names every addition it makes.

Requires

  • A database battery. The resolver adds the default one for you and tells you why.
  • An auth battery. The resolver adds the default one for you and tells you why.

Pairs well with

  • An email battery. Suggested, never added for you.

Build a repo with Polar

Free and MIT. The builder opens with Polar picked. You download the zip right away, and we email you the link too.

Presets

Presets that already include Polar

A tested selection with its own file tree and its own generated CLAUDE.md. Start from one instead of from blank.