Skip to content

Payments

Next.js boilerplate with Stripe

Hosted Checkout, a customer portal you never build, and webhooks you test locally.

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

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

Maintained by @raviMITNext.js on Vercel

From the manifest

Should you pick Stripe?

Pick it if

SaaS selling to businesses and consumers in many countries. Best when you need real invoices, dunning, proration and a tax story your accountant will accept.

Watch out for

  • You are the merchant of record. You owe sales tax, VAT and GST yourself. Stripe Tax calculates and files, but the liability is yours. Merchant-of-record vendors (Polar, Dodo, Lemon Squeezy) take that liability, at a higher rate.
  • Webhooks are mandatory. Every subscription change reaches you asynchronously, at least once and out of order. Skip idempotency and you will double-grant entitlements in production.
Show 3 more
  • Stripe is not available in every country, and some business types are not allowed. Check both before you build.
  • The API is deeper than you need on day one. The failure mode is over-modelling billing before you have ten paying customers.
  • The Stripe CLI's stripe listen forwards real webhooks to localhost, so you test the full loop before you deploy.

What it costs

2.9% + 30c per successful US card charge. No setup or monthly fee. Stripe Billing adds 0.7% of billing volume. Stripe Tax is extra, charged per transaction where you are registered to collect.

Prices change. Check with Stripe before you commit.

registry/tested.yaml

Tested with Stripe

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

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

Environment variables

  • STRIPE_SECRET_KEYRequired

    Server-side secret key. Test mode keys start with sk_test_, live keys with sk_live_. This value must never reach the browser bundle. Until it is set, /pricing still renders and every buy button explains that billing is not set up.

    Placeholder
    sk_test_replace_me
  • STRIPE_WEBHOOK_SECRETRequired

    Signing secret for the endpoint at /api/webhooks/stripe. Locally, run bun run stripe:listen and copy the whsec_ value it prints. In production, copy it from the endpoint's page in the Stripe dashboard. The local secret and the deployed secret are different values.

    Placeholder
    whsec_replace_me
  • NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEYRequiredPublic, reaches the browser

    Publishable key, safe in the browser. Hosted Checkout does not need it, but Stripe.js does the moment you add Elements or embedded Checkout, and bun run verify uses it to assert that your publishable and secret keys are in the same mode. A pk_live paired with an sk_test is the most common Stripe misconfiguration there is.

    Placeholder
    pk_test_replace_me
  • STRIPE_AUTOMATIC_TAXOptional

    Set to true to have Checkout calculate sales tax, VAT and GST on every session. Off by default because Stripe rejects a session with automatic tax on an account that has not activated Stripe Tax, which is every account on its first day. bun run verify fails while this is set and Stripe Tax is not active.

    Placeholder
    false
  • BILLING_PRICE_PRO_MONTHLYOptional

    The Stripe price (price_...) for the catalogue price pro-monthly. bun run billing:sync-plans creates it and prints this line. Test and live mode have different ids.

    Placeholder
  • BILLING_PRICE_PRO_YEARLYOptional

    The Stripe price (price_...) for pro-yearly, a yearly recurring price.

    Placeholder
  • BILLING_PRICE_PRO_LIFETIMEOptional

    The Stripe price (price_...) for pro-lifetime, a one-time price. One payment grants the Pro plan for good; a refund or dispute takes it away.

    Placeholder
  • BILLING_PRICE_TEAM_MONTHLYOptional

    The Stripe price (price_...) for team-monthly.

    Placeholder
  • BILLING_PRICE_TEAM_YEARLYOptional

    The Stripe price (price_...) for team-yearly.

    Placeholder

Dependencies

  • server-only^0.0.1
  • stripe~22.6.2

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 stripe:listen

    stripe listen --forward-to localhost:3000/api/webhooks/stripe

  • bun run stripe:trigger

    stripe trigger checkout.session.completed

MCP server

  • stripe

    URL
    https://mcp.stripe.com

Files it writes

10 files, at these exact paths.

  • scripts/3 files
    • billing/3 files
      • prune-events.ts
      • reconcile.ts
      • sync-plans.ts
  • src/6 files
    • app/1 file
      • api/1 file
        • webhooks/1 file
          • stripe/1 file
            • route.ts
    • lib/5 files
      • billing/5 files
        • provider.ts
        • stripe-objects.test.ts
        • stripe-objects.ts
        • stripe-provider.test.ts
        • stripe.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 Stripe 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 (4)

Loaded when the agent opens a matching file.

Stripe translates, the shared billing core writes the store

Loads onsrc/lib/billing/**scripts/billing/**.claude/rules/stripe-billing-store.md

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

FileJob
src/lib/billing/stripe.tsthe one lazy Stripe client, API version pinned
src/lib/billing/provider.tsbillingProvider, the BillingProvider contract from ./types: checkout, portal, webhook verify, translate
src/lib/billing/stripe-objects.tspure reads of Stripe objects (status, periods, invoice parent, purchase status), tested
src/app/api/webhooks/stripe/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 Polar, Dodo and Lemon Squeezy. Hold the line between the two:

  • The adapter never writes billing state and never sends mail. translate and syncCheckout return BillingEvent[] (customer.linked, subscription.changed, purchase.changed, payment.receipt, payment.failed). applyBillingEvents in webhook.ts 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 imports stripe. Not a page, not a component, not entitlements.ts. A feature that needs Stripe data gets a method on BillingProvider (all four providers implement it) or a field on a BillingEvent. Never a branch on billingProvider.id in shared code.
  • Re-read, then translate. Every handler retrieves the subscription, invoice or Checkout Session by id through the pinned client before mapping it. Payloads arrive out of order and in the API version of whatever endpoint sent them.
  • Version-sensitive fields live in stripe-objects.ts. The renewal date on subscription items, the subscription id under invoice.parent, cancel_at versus cancel_at_period_end, a charge's refund and dispute flags. That file has no import "server-only", so its tests load it. When a Stripe upgrade moves a field, change it there and add a fixture.
  • Statuses are normalized, and Stripe's word is kept. normalizeSubscriptionStatus maps Stripe's status onto the shared SubscriptionStatus (incomplete_expired becomes expired, an unknown word grants nothing). The row stores both, status and providerStatus.
  • One-time and subscription share one checkout. createCheckout picks mode: "payment" for a one_time catalogue price and mode: "subscription" otherwise, and puts { userId, planSlug, priceId } on the session and on what it creates (the PaymentIntent and invoice, or the subscription).

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

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".

Plans live in pricing.ts, Stripe ids live in env, amounts never come from the client

Loads onsrc/lib/pricing.tssrc/lib/billing/**scripts/billing/**.claude/rules/stripe-pricing-source-of-truth.md

The catalogue is code: src/lib/pricing.ts. Plans, features, and a monthly, yearly or one-time price for each, in minor units. /pricing, /billing and checkout all read it. Stripe holds the matching prices, and each catalogue price is tied to one by an env var named after its id:

pro-monthly   ->  BILLING_PRICE_PRO_MONTHLY=price_...
pro-lifetime  ->  BILLING_PRICE_PRO_LIFETIME=price_...   (a one-off price)

src/lib/billing/price-refs.ts reads those with literal process.env reads and satisfies Record<PriceId, ...>, so a price with no env line is a type error.

Hold to these:

  • No price_... string in code. Not in a component, not in pricing.ts, not in a script. Test and live mode have different ids, and the env var is what lets one build serve both.
  • Never trust an amount, currency, interval, quantity or trial from the client. A buy button is <a href="/billing/checkout?price=pro-monthly">. The route looks the id up in the catalogue, startCheckout resolves the Stripe price from env, and the session is built from those. Anything else in the request is ignored.
  • An unknown id is refused, not passed through. findPrice returns null and the route redirects to /pricing?error=unknown-price. isValidPriceRef rejects an env value that is not a price_... id before Stripe sees it.
  • The catalogue amount is for display. Stripe charges its own. Keep them equal with bun run billing:sync-plans -- --check, which exits 1 on any drift in amount, currency or interval. bun run verify runs the same check on every set BILLING_PRICE_*.
  • Stripe prices are immutable once used. A new amount is a new Stripe price and usually a new catalogue id (pro-monthly-v2). Never rename a catalogue id somebody has paid for: it is stored on their subscription and purchase rows.
  • Money is integers in minor units. 1900 is $19.00. Render only through formatPrice / formatAmount in src/lib/billing/format.ts, which reads the currency's own exponent (JPY has none, KWD has three).

To add a plan or a price: edit src/lib/pricing.ts, add its line to price-refs.ts, run bun run billing:sync-plans (it creates the product plan_<slug> and the price, with the catalogue id as its lookup key) and paste the printed env line. The /add-plan skill walks through it.

Stripe objects are server-only

Loads onsrc/lib/billing/**src/app/api/webhooks/stripe/**src/app/**.claude/rules/stripe-server-boundary.md

The Stripe SDK is constructed exactly once, in src/lib/billing/stripe.ts, which starts with import "server-only". Nothing else calls new Stripe(...) (the verify check builds its own because it runs outside Next.js).

It is built lazily, by getStripe(). next build imports every module a page can reach, so a client built at module scope throws during the build of any deployment with no payment secrets yet: a preview, a fresh clone. The exported stripe is a proxy onto the same memoised client, so stripe.checkout.sessions.create(...) reads like every Stripe example while importing the module costs nothing. Do not "simplify" it into const stripe = new Stripe(...).

Never import stripe, provider.ts or @/lib/billing into a "use client" file or a module a client component imports. server-only turns that into a build error rather than a leaked STRIPE_SECRET_KEY, but the error is the backstop, not the design. Client components may import the pure files (catalog, format, types, entitlement).

Never read process.env.STRIPE_SECRET_KEY outside stripe.ts and the adapter's missingConfig() (which only checks it is set and not a placeholder, so billing can say "not set up" instead of throwing).

Never build a return URL from a request header. Success, cancel and portal return URLs come from returnOrigin() in src/lib/billing/origin.ts, which checks the host against NEXT_PUBLIC_APP_URL and the platform's own deployment URLs. x-forwarded-host is client-settable.

Checkout and the portal are hosted pages. The flow is:

  1. A buy button is a plain <a href="/billing/checkout?price=pro-monthly"> (never next/link: a prefetch would create a checkout session).
  2. GET /billing/checkout reads the session. Signed out, it counts the visitor's address and sends them to sign-up with the checkout as the way back. Signed in, it calls startCheckout({ user, priceId }).
  3. startCheckout checks the price and impersonation, counts the attempt against the rate limit (per user and per address, in Postgres), checks that the user does not already own it, config and email, then calls the adapter's createCustomer (once per user) and createCheckout. Over the limit, Stripe is never called.
  4. The route answers 303 to session.url. Any BillingError becomes a 303 to /billing?error=<code>, which renders a sentence. Never a 500.

The portal is the openBillingPortal server action. A server action is a public POST endpoint, so it reads the user from the session with requireUser() and derives the Stripe customer from the stored mapping. Never take a user id, email or customer id from form data.

Billing never imports an auth SDK. src/lib/billing/user.ts turns the SessionUser from @/lib/auth/session into a BillingUser, and that is the only place the two meet. While an admin impersonates a user (impersonatedBy is set), checkout and the portal refuse with BillingError("impersonating"): both act on the customer's card.

BillingUser.email can be null. Do not paper over it with ?? "". requireBillingEmail() throws no-email before a Stripe customer is created, because a customer with no address never gets a receipt, an invoice or a failed-payment warning.

Do not add a route that proxies arbitrary Stripe calls (/api/stripe/[...path]). It is a credential-forwarding endpoint with extra steps.

Verify every Stripe webhook, keep every handler idempotent

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

The webhook route is an unauthenticated public endpoint. The signature is the only thing between a Stripe 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, and the Stripe parts are verifyWebhook and translate in provider.ts. Keep it that way: nothing Stripe-specific in the route.

Read the body as text. processWebhook calls request.text() and hands those exact bytes to verifyWebhook. request.json() reorders keys and every signature check fails. Never add a body parser or middleware in front of it.

Always verify with constructEventAsync, the raw body, the stripe-signature header and STRIPE_WEBHOOK_SECRET. A missing or placeholder secret throws WebhookConfigError (500, Stripe keeps retrying until you set it). A bad signature throws WebhookSignatureError (400, a retry would fail the same way). There is no "skip it locally": bun run stripe:listen gives you a real secret.

Only the events in HANDLED_EVENTS do work. Everything else answers 200 ignored and writes nothing. Adding an event means adding it there, handling it in translate, and subscribing the dashboard endpoint to it.

The claim comes before any side effect. processWebhook inserts stripe:<event id> into billing_processed_events first. A duplicate loses the insert and answers 200 duplicate. On a throw the claim is released and the answer is 500, so Stripe's retry runs the work again. On success it is stamped complete. Do not reorder those, and do not write that table by hand.

Receipts and dunning mail are events, not calls. translate returns payment.receipt (from invoice.paid, amount above zero) and payment.failed (from invoice.payment_failed). applyBillingEvents sends them inside the claim and logs a failure instead of throwing: the payment is already recorded, and a 500 now would redeliver it and mail the customer twice.

Re-read, never trust the payload. Every case in translate retrieves the object by id first (subscription, invoice, Checkout Session with line_items and payment_intent.latest_charge expanded). Events arrive out of order, and a payload is shaped by the API version of the endpoint that sent it, which can be older than the pinned one.

One-time payments are decided by the session, not the event name. checkout.session.completed in mode: "payment" is only paid when payment_status is paid (or no_payment_required for a 100% coupon). Bank debits complete days earlier with unpaid, then send checkout.session.async_payment_succeeded or _failed. Refunds (charge.refunded) and disputes (charge.dispute.created) find the session through the PaymentIntent and rewrite the same purchase row, which revokes the plan.

Status codes are the retry protocol. 400 bad signature, 200 processed, duplicate or ignored, 500 for anything transient. Never swallow an error into a 200: that drops a paid subscription on the floor.

Return fast. Stripe gives up at about 20 seconds. maxDuration = 60 is headroom, not budget.

The ledger is not a log. bun run billing:prune-events deletes completed rows older than 30 days and reports claims that never completed. Do not delete a stuck claim to "clean up": it hands the next redelivery a clean slate to re-run its side effects on.

Skills (2)

Invoked by name.

  • /add-plan

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

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

  • /test-webhook

    Exercise the Stripe webhook endpoint locally. Forward real events with the CLI, buy a subscription and a one-time price, refund one, assert idempotency, 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 Stripe 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 Stripe

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

Presets

Presets that already include Stripe

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