How to add payments to a Next.js app

There are three honest answers, and which one is right depends on a question nobody asks first: how much of billing do you want to own? Stripe is not the hard part. The hard part is everything that has to be true after the card clears, and that work is the same whichever SDK you install.

What Stripe gives you, and what you are still holding

Worth laying out before choosing, because every route below is really a decision about who owns the right-hand column.

Stripe hands you

  • Checkout Sessions, so you never touch a card number
  • Products, prices, coupons and tax, configured in their dashboard
  • A Customer Portal your users can manage their own plan in
  • Webhook events for every lifecycle change

You still build

  • A webhook route that verifies the signature on the raw body, before any JSON parsing
  • Idempotency, because Stripe retries and will deliver the same event twice
  • A subscriptions table, and the state machine over trialing, active, past_due and canceled
  • Reconciliation for the events you missed while your deploy was down
  • A read path your UI can call on every render without hammering Stripe
  • The gate itself: the code that decides what a given tier may do

The three routes

1. Stripe directly

Correct when you want no third party in the path.

Install the Stripe SDK, create a Checkout Session in a route handler, and handle the webhook. For a single one-time price this is genuinely an afternoon. The cost arrives later, with the second price, the first failed renewal and the first missed webhook, and it is the work listed above rather than anything Stripe did wrong.

  • No vendor between you and your money
  • Every Stripe feature available the day Stripe ships it
  • No cost beyond Stripe processing fees
  • Your repo holds a Stripe secret key and a webhook secret
  • You own the subscription state machine and its edge cases
  • You are the merchant of record, so VAT and sales tax are yours

2. A merchant of record

Correct when you do not want to own sales tax.

Paddle, Lemon Squeezy and Dodo Payments sell to your customer on your behalf. They register for VAT in the places that require it, charge the right rate, file the returns and carry the liability. You get a cleaner tax life and a higher effective rate on every transaction.

  • Global VAT and sales tax handled, including registration and filing
  • Chargebacks and fraud are largely their problem
  • One integration covers invoicing and dunning
  • A higher cut than Stripe processing alone
  • They own the customer relationship on the receipt
  • Migrating off later means migrating subscriptions, not just code

3. A hosted entitlements layer on your own Stripe account

What Spark Stack ships, and what the rest of this page documents.

Keep your own Stripe account and let something else hold the plans, the webhook and the subscription state. Your app asks one question, "what may this email do", and gets a typed answer. This is the narrowest of the three: it removes the work in the right-hand column above without taking over your payments, and it is not a Stripe alternative. Stripe still processes every card and still charges its ordinary fees.

  • No Stripe secret key in your repository
  • No webhook endpoint of your own to secure, verify or reconcile
  • Plan and price changes need no deploy
  • A service between your app and its billing state, which is a dependency
  • The pricing page is a page on another domain, not on yours
  • You still write the gate in your own components

Route three, concretely, in Spark Stack

Spark Stack ships lib/spark-stripe/ as a client kit against a SparkPay backend. Two environment variables, and only one of them is a secret:

# A public identifier. Safe to commit.
NEXT_PUBLIC_SPARK_PAY_APP_ID=your-app-id

# The only secret the app needs. No Stripe secret key, ever.
SPARK_STRIPE_WEBHOOK_SECRET=whsec_from_the_dashboard

Then the gate is the whole integration:

import { SparkGate, useSparkStatus } from './SparkStripe';
import { getLimit } from './sparkstripe-server'; // no 'use client'

function Exporter({ email }: { email: string }) {
  const { status } = useSparkStatus(email);
  if (!status) return null;

  // SparkGate takes the status it should judge. With no minTier it falls
  // back to the paywall flag, which already accounts for the trial.
  return (
    <SparkGate status={status}>
      {/* getLimit returns the plan's CAP, not what is left. */}
      <ExportButton monthlyCap={getLimit(status, 'exports')} />
    </SparkGate>
  );
}

What the kit exports

The exports of the Spark Stack payments kit and what each one does
ExportWhat it does
useSparkStatus(email)React hook, from SparkStripe.tsx. Returns status, loading, tier, isPaid and showPaywall.
useCheckout()From SparkStripe.tsx. Sends the user to hosted checkout for a tier.
SparkGate / SparkBadgeFrom SparkStripe.tsx. The wall below minTier, and the current-plan badge.
getStatusCached(email)From sparkstripe-server.ts. Server read, cached 60s against a revalidate tag.
getLimit / hasFeatureFrom sparkstripe-server.ts. One plan limit or feature flag off a status; -1 means unlimited.
guard / guards.<tier>From sparkstripe-server.ts. Route-handler wrappers, generated from plans.json.
createWebhookHandler()From sparkstripe-server.ts. Verifies the signature and hands you a typed payload. Re-export its POST from your route.

One rule, and breaking it fails silently

The kit is two files on purpose. sparkstripe-server.ts has no 'use client' and holds the status reads, limits, gate and webhook, so a route handler, a Server Component or a webhook can call it. SparkStripe.tsx is the React half. Do not re-export the server half through the client one to tidy the import paths: that turns those functions back into client references, and a webhook route built that way compiles clean, passes CI, deploys, and then throws Attempted to call POST() from the server but POST is on the client on every delivery, with nothing reporting it. Subscription state simply stops propagating: a customer who paid stays un-activated, one who cancelled stays paid.

Questions

Do I need a Stripe account to charge money from a Next.js app?

For routes one and three, yes, and it is yours. For route two the merchant of record sells on your behalf, so the account that faces the card network is theirs and your customer sees their name on the receipt.

Can I keep Stripe keys out of my repository?

Only if something else holds them. Calling Stripe from your own route handler requires a secret key in your environment, which is fine but is one more secret you are responsible for rotating. On route three the only secret the app needs is a webhook signing secret, and the app id it talks to is a public identifier that is safe to commit.

What actually breaks first when you wire Stripe up by hand?

Webhook signature verification, because a framework that has already parsed the request body gives you a body Stripe cannot sign-check. After that it is duplicate delivery: Stripe retries, so an event handler that is not idempotent grants the same entitlement twice or emails the same receipt twice.

Does a hosted billing layer replace Stripe fees?

No, and any page that implies otherwise is wrong. Stripe still processes the payment and still takes its percentage. A layer on top replaces the plans, the webhook and the entitlement read, which is engineering time rather than card fees.

Where do I see what Spark Stack costs?

On its SparkPay pricing page, which is the page checkout charges from and the only place the figure is maintained. Quoting it here would mean a second number that goes stale the day a plan changes.