Skip to content

Payments

Next.js boilerplate with Lemon Squeezy

Merchant of record with license keys built in. VAT and sales tax are its problem.

Subscriptions and one-time purchases on Lemon Squeezy, the merchant of record: hosted checkout for monthly, yearly and lifetime prices, the signed customer portal, refunds that revoke access, and X-Signature verified webhooks made idempotent without a delivery id. Plans live in src/lib/pricing.ts and /pricing renders with no keys.

What Lemon Squeezy adds to the agent layer: 2 rules · 2 skills · 8 solution docs

Maintained by @raviMITNext.js on Vercel

From the manifest

Should you pick Lemon Squeezy?

Pick it if

Solo founders and small teams selling SaaS, desktop apps or digital downloads worldwide. You get checkout, tax, license keys and a customer portal on day one, with no VAT registration anywhere. Strong fit for software sold with license keys, because Lemon Squeezy issues and validates them itself.

Watch out for

  • Lemon Squeezy is the seller on the statement and the invoice. Tax liability moves to it, which is the point. Your brand is not what the customer sees on their card line.
  • The base rate, 5% + 50c, is above Dodo's 4% + 40c and matches Polar's free plan. Run the numbers against both before you launch.
Show 3 more
  • Owned by Stripe since 2024. In January 2026 it said its goal is an easy migration to Stripe Managed Payments. Read that plan before you build on it.
  • The API cannot create products or prices. You create the variants in the dashboard; billing:sync-plans checks them against src/lib/pricing.ts and finds their ids for you.
  • Webhooks carry no delivery id and retry only three times (5s, 25s, 125s). A handler that is down for three minutes loses events, so a nightly reconcile from the API ships with it.

What it costs

5% + 50c per transaction, no monthly fee. Non-US payments and PayPal each add 1.5%, and subscriptions add 0.5%. Tax calculation, filing and remittance are included.

Prices change. Check with Lemon Squeezy before you commit.

registry/tested.yaml

Tested with Lemon Squeezy

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 Lemon Squeezy adds to the repo

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

Environment variables

  • LEMONSQUEEZY_API_KEYRequired

    API key from Settings -> API. A key created while the store is in test mode only ever sees test-mode data, and a live key only live data. Server-side only: it can refund orders and cancel subscriptions. Until it is set, /pricing still renders and every buy button explains that billing is not set up.

    Placeholder
    replace_me_with_a_test_mode_api_key
  • LEMONSQUEEZY_STORE_IDRequired

    Numeric id of the store that sells your plans (Settings -> Stores). Every checkout, variant check and webhook is scoped to it, so an account with several stores only ever acts on this one.

    Placeholder
    replace_me_with_the_numeric_store_id
  • LEMONSQUEEZY_WEBHOOK_SECRETRequired

    The signing secret you typed when creating the webhook (Settings -> Webhooks), 6 to 40 characters. Lemon Squeezy sends an X-Signature header holding the hex HMAC-SHA256 of the raw body with this value. Use a different secret for the test-mode and live-mode webhooks.

    Placeholder
    replace_me_6_to_40_chars
  • LEMONSQUEEZY_MODEOptional

    "test" or "live". Defaults to test so a fresh clone cannot take a real payment by accident. Every checkout is created in this mode, webhooks from the other mode are dropped, and bun run verify fails when the API key's mode disagrees.

    Placeholder
    test
  • BILLING_PRICE_PRO_MONTHLYOptional

    The Lemon Squeezy variant id (digits) for the catalogue price pro-monthly: a monthly subscription variant. bun run billing:sync-plans finds it and prints this line. Test and live mode have different ids.

    Placeholder
  • BILLING_PRICE_PRO_YEARLYOptional

    The Lemon Squeezy variant id for pro-yearly, a yearly subscription variant.

    Placeholder
  • BILLING_PRICE_PRO_LIFETIMEOptional

    The Lemon Squeezy variant id for pro-lifetime, a single-payment variant. One payment grants the Pro plan for good; a full refund takes it away.

    Placeholder
  • BILLING_PRICE_TEAM_MONTHLYOptional

    The Lemon Squeezy variant id for team-monthly.

    Placeholder
  • BILLING_PRICE_TEAM_YEARLYOptional

    The Lemon Squeezy variant id for team-yearly.

    Placeholder

Dependencies

  • @lemonsqueezy/lemonsqueezy.js^4.0.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

Files it writes

13 files, at these exact paths.

  • scripts/4 files
    • billing/4 files
      • prune-events.ts
      • reconcile.ts
      • sync-plans.ts
      • test-webhook.ts
  • src/8 files
    • app/1 file
      • api/1 file
        • webhooks/1 file
          • lemonsqueezy/1 file
            • route.ts
    • lib/7 files
      • billing/7 files
        • lemonsqueezy-fixtures.ts
        • lemonsqueezy-objects.test.ts
        • lemonsqueezy-objects.ts
        • lemonsqueezy-provider.test.ts
        • lemonsqueezy-signature.ts
        • lemonsqueezy.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 Lemon Squeezy 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.

Lemon Squeezy translates, the shared billing core writes

Loads onsrc/lib/billing/**scripts/billing/**.claude/rules/lemonsqueezy-billing-discipline.md

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

FileJob
src/lib/billing/lemonsqueezy.tslazy SDK setup, unwrap(), the mode and the store id
src/lib/billing/provider.tsbillingProvider, the BillingProvider contract from ./types: checkout, portal, webhook verify, translate
src/lib/billing/lemonsqueezy-objects.tspure reads of Lemon Squeezy objects (payload schemas, statuses, orders, invoices, variant drift), tested
src/lib/billing/lemonsqueezy-signature.tsthe X-Signature HMAC check
src/lib/billing/lemonsqueezy-fixtures.tsreal webhook bodies for tests and billing:test-webhook. Never imported by the app
src/app/api/webhooks/lemonsqueezy/route.tsthree lines into the shared processWebhook
scripts/billing/*.tssync-plans, reconcile, prune-events, test-webhook

Everything else under src/lib/billing, plus /pricing, /billing and src/components/billing, is shared code that works the same for Stripe, Polar and Dodo. Hold the line between the two:

  • The adapter never writes billing state and never sends mail. translate returns BillingEvent[]; applyBillingEvents in webhook.ts is the only writer. The adapter's store calls are reads: getUserIdForCustomer and findPurchaseByPaymentId.
  • Nothing outside the adapter imports @lemonsqueezy/lemonsqueezy.js. A feature that needs Lemon Squeezy data gets a method on BillingProvider or a field on a BillingEvent. Never a branch on billingProvider.id in shared code.
  • Set the SDK up lazily. ensureLemonSqueezy() runs lemonSqueezySetup on first use. next build imports modules with no secrets set, so a module-scope setup breaks every preview build.
  • The SDK never throws on an HTTP error. It returns { data, error }. Every call goes through unwrap(), or checks error and statusCode itself (a 404 on a re-read is a fallback case, not an outage).
Plans live in pricing.ts, variant ids live in env

The catalogue is src/lib/pricing.ts. Each price is sold as one Lemon Squeezy variant, named by an env var: pro-monthly is BILLING_PRICE_PRO_MONTHLY, read literally in price-refs.ts. isValidPriceRef accepts digits only.

  • No variant, product or store id in code. Test and live mode have different ids.
  • The catalogue decides one-time versus subscription. A one_time price must point at a single-payment variant, month and year at subscription variants. billing:sync-plans and verify check the kind, amount, currency, interval, trial, store and mode of every set id with variantDrift.
  • Lemon Squeezy's API cannot create products. The dashboard is where they are made; billing:sync-plans finds matching variants and prints the env lines. Never "fix" drift by editing the check.
Checkout sells one variant, priced on the server

createCheckout is called by the shared startCheckout with a catalogue price that was already validated.

  • productOptions.enabledVariants is always [variantId]. Without it the hosted page lets the buyer switch to any variant of the product.
  • Never pass customPrice from anything a user sent. It overrides the price of every renewal, not only the first charge.
  • checkoutData.custom carries { userId, planSlug, priceId }. Lemon Squeezy echoes it as meta.custom_data on every order and subscription webhook. It names the user; it never names the plan (a buy link can carry any custom data, the variant that was paid for cannot be faked).
  • Trials are a variant setting. A recurring price without trialDays sends skipTrial: true, so the catalogue stays the promise.
  • testMode comes from LEMONSQUEEZY_MODE (default test) on every checkout, and verify fails when the API key's own mode disagrees.
The portal URL is a credential

urls.customer_portal is pre-signed and valid for 24 hours. createPortal fetches it fresh from the subscription (or the customer) on every click. Never store it, log it or put it in an email. A buyer with no subscription has no portal: Lemon Squeezy returns null and so does createPortal.

Scripts

Scripts that read the store or the adapter run with bun --conditions=react-server. verify and billing:test-webhook run with bun and import only pure files (catalog, price-refs, format, types, lemonsqueezy-objects, lemonsqueezy-signature, lemonsqueezy-fixtures) and the SDK. Importing lemonsqueezy.ts or provider.ts there throws on server-only.

The contract itself (types, entitlement rules, the store and its tables) is in the shared layer. Its rule is "Billing is one shared layer with one provider adapter".

Verify X-Signature on the raw body, and key every delivery without a delivery id

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

The webhook route is a public URL with no session. The signature is the only thing between a Lemon Squeezy 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 Lemon Squeezy parts are verifyWebhook and translate in provider.ts. Keep it that way: nothing Lemon Squeezy specific in the route.

Verify first, on the raw body
  • processWebhook reads await request.text() and hands those exact bytes to verifyWebhook. Never parse, re-stringify or add a body parser in front.
  • isValidSignature in lemonsqueezy-signature.ts compares the hex HMAC-SHA256 in constant time. Use it; do not write a second one.
  • A missing or placeholder LEMONSQUEEZY_WEBHOOK_SECRET throws WebhookConfigError (500, Lemon Squeezy retries). A missing or wrong X-Signature throws WebhookSignatureError (400). The body is parsed only after the signature matched.
  • No "local dev" bypass. bun run billing:test-webhook signs fixtures with your real secret.
The idempotency key is built, not received

Lemon Squeezy sends no delivery id, and meta.webhook_id names the webhook configuration, not the delivery. webhookEventKey builds one from what a retry keeps and a real change moves:

<event_name>:<data.type>:<data.id>:<data.attributes.updated_at>

The shared pipeline claims lemonsqueezy:<key> before any side effect. A duplicate answers 200 duplicate; a throw releases the claim and answers 500. Never drop the event name from the key (subscription_updated and subscription_cancelled share one updated_at) and never change its shape without a plan for in-flight retries.

What translate may trust
  • Orders come from the signed payload. An order only moves forward (paid, then refunded), and the shared purchase upsert never moves a row backwards, so the order deliveries land in cannot matter.
  • Subscriptions and invoices are re-read with getSubscription and getSubscriptionInvoice. They move both ways, and subscription_updated fires beside almost every other event. The payload is only a fallback when the API answers 404.
  • A purchase is a one-time price by the catalogue, never by the payload. order_created fires for every order, including a subscription's first payment. Only an order whose variant maps to a one_time catalogue price (or that already has a purchase row) becomes a purchase.
  • Custom data names the user, not the plan. The plan comes from the variant. A subscription's plan comes from its current variant only.
  • Drop other-mode and other-store events. translate returns no events when meta.test_mode disagrees with LEMONSQUEEZY_MODE or store_id is not LEMONSQUEEZY_STORE_ID. A 4242 test purchase must never entitle anyone on production.
Never answer 200 for money you did not record

A paid one-time order that no local user can be matched to throws UnmatchedOrderError: 500, red in the dashboard, retried. A quiet 200 would mark it done and make that money unrecordable through the webhook. Money fields are validated with zod (Number.isInteger, no ?? 0, no ?? "usd").

Mail is an event

translate returns payment.receipt (paid one-time orders, paid subscription invoices) and payment.failed (declined renewals whose invoice is still unpaid). The core sends them inside the claim and logs a failure instead of throwing. Never send mail from the adapter.

The handled events are a list you keep in sync

HANDLED_EVENTS in provider.ts is the source of truth. The events ticked on the webhook in the dashboard must match it, for both the test-mode and live-mode webhooks. Anything else answers 200 ignored and writes nothing.

Three retries is a short fuse

Lemon Squeezy retries a failed delivery three more times (about 5s, 25s, 125s) and then stops. bun run billing:reconcile rebuilds subscriptions and recent orders from the API; schedule it nightly. billing:prune-events trims completed claims and reports stuck ones. Never delete a stuck claim to "clean up".

Skills (2)

Invoked by name.

  • /add-plan

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

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

  • /test-webhook

    Exercise the Lemon Squeezy webhook endpoint. Signed one-time purchases and refunds on localhost, forged requests, duplicates, mode and store guards, subscriptions and failed renewals.

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

Solution docs (8)

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 8

How it fits

What Lemon Squeezy 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 Lemon Squeezy

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