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 moreShow fewer
- 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 listenforwards 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.
- 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.
- Where to get it
- https://dashboard.stripe.com/test/apikeys
- Placeholder
- sk_test_replace_me
STRIPE_WEBHOOK_SECRETRequired
Signing secret for the endpoint at /api/webhooks/stripe. Locally, run
bun run stripe:listenand 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.- Where to get it
- https://docs.stripe.com/webhooks#verify-official-libraries
- 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 verifyuses 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.- Where to get it
- https://dashboard.stripe.com/test/apikeys
- Placeholder
- pk_test_replace_me
STRIPE_AUTOMATIC_TAXOptional
Set to
trueto 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 verifyfails while this is set and Stripe Tax is not active.- Where to get it
- https://dashboard.stripe.com/settings/tax
- Placeholder
- false
BILLING_PRICE_PRO_MONTHLYOptional
The Stripe price (price_...) for the catalogue price
pro-monthly.bun run billing:sync-planscreates it and prints this line. Test and live mode have different ids.- Where to get it
- https://dashboard.stripe.com/test/products
- Placeholder
BILLING_PRICE_PRO_YEARLYOptional
The Stripe price (price_...) for
pro-yearly, a yearly recurring price.- Where to get it
- https://dashboard.stripe.com/test/products
- 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.- Where to get it
- https://dashboard.stripe.com/test/products
- Placeholder
BILLING_PRICE_TEAM_MONTHLYOptional
The Stripe price (price_...) for
team-monthly.- Where to get it
- https://dashboard.stripe.com/test/products
- Placeholder
BILLING_PRICE_TEAM_YEARLYOptional
The Stripe price (price_...) for
team-yearly.- Where to get it
- https://dashboard.stripe.com/test/products
- 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:
| File | Job |
|---|---|
src/lib/billing/stripe.ts | the one lazy Stripe client, API version pinned |
src/lib/billing/provider.ts | billingProvider, the BillingProvider contract from ./types: checkout, portal, webhook verify, translate |
src/lib/billing/stripe-objects.ts | pure reads of Stripe objects (status, periods, invoice parent, purchase status), tested |
src/app/api/webhooks/stripe/route.ts | three lines into the shared processWebhook |
scripts/billing/*.ts | sync-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.
translateandsyncCheckoutreturnBillingEvent[](customer.linked,subscription.changed,purchase.changed,payment.receipt,payment.failed).applyBillingEventsinwebhook.tsis 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, notentitlements.ts. A feature that needs Stripe data gets a method onBillingProvider(all four providers implement it) or a field on aBillingEvent. Never a branch onbillingProvider.idin 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 underinvoice.parent,cancel_atversuscancel_at_period_end, a charge's refund and dispute flags. That file has noimport "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.
normalizeSubscriptionStatusmaps Stripe's status onto the sharedSubscriptionStatus(incomplete_expiredbecomesexpired, an unknown word grants nothing). The row stores both,statusandproviderStatus. - One-time and subscription share one checkout.
createCheckoutpicksmode: "payment"for aone_timecatalogue price andmode: "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 inpricing.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,startCheckoutresolves 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.
findPricereturns null and the route redirects to/pricing?error=unknown-price.isValidPriceRefrejects an env value that is not aprice_...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 verifyruns the same check on every setBILLING_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/formatAmountinsrc/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:
- A buy button is a plain
<a href="/billing/checkout?price=pro-monthly">(nevernext/link: a prefetch would create a checkout session). GET /billing/checkoutreads 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 callsstartCheckout({ user, priceId }).startCheckoutchecks 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'screateCustomer(once per user) andcreateCheckout. Over the limit, Stripe is never called.- The route answers 303 to
session.url. AnyBillingErrorbecomes 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.
- Free trials in Stripe that do not leak accesstrialing is an entitled status and trial_will_end is not a payment. Where trials are configured, which events actually matter, and how to stop one person taking ten trials.docs/solutions/stripe/free-trials-that-do-not-leak.md
- Idempotent Stripe webhooks, or claim the event id before you do the workStripe delivers at least once, retries for three days and arrives out of order. An event ledger claimed before the handler runs is what stops double grants and lost updates.docs/solutions/stripe/idempotent-stripe-webhooks.md
- One-time payments with Stripe Checkout, from lifetime deal to refundA lifetime deal is a payment-mode Checkout Session, not a subscription. Which id to key it on, when it is really paid, how a receipt goes out, and how a refund or dispute takes the access back.docs/solutions/stripe/one-time-payments-with-stripe-checkout.md
- Proration when a customer changes plan, and why you should let the portal do itUpgrades, downgrades and seat changes each want different proration behaviour. What Stripe actually does, how to preview the amount, and when to build the flow yourself.docs/solutions/stripe/proration-when-plans-change.md
- Reconciling after a missed Stripe webhookA customer paid and the app does not know. How to find the gap, replay or re-sync the affected subscriptions and one-time purchases, and build a reconciliation job so the next outage is boring.docs/solutions/stripe/reconciling-a-missed-webhook.md
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.
Cannot be combined with
Compared with the alternatives
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.