Skip to content

Generated code

stripekit init scaffolds billing code into your Next.js app that you own and can edit — there's no runtime dependency on stripekit. Every file is marked "Generated by stripekit. Safe to edit."

What gets written

lib/stripe/
  state.ts        # StripeCustomerState + the CustomerStore interface
  client.ts       # the configured Stripe client (server-only)
  sync.ts         # syncStripeCustomerState + getOrCreateCustomerId
  customer.ts     # getCustomerState / isSubscribed / hasFeature
  store.ts        # your chosen adapter (KV or Drizzle)
  auth.ts         # getUserId, wired to your auth library
  schema.ts       # (Drizzle) the stripe_customers table
  db.ts           # (Drizzle) a pooled Postgres client
app/api/stripe/
  webhook/route.ts   # signature-verified webhook handler
  checkout/route.ts  # start a Checkout Session by lookup key
  portal/route.ts    # open the customer portal

The sync pattern

The core idea (from "How I Stay Sane Implementing Stripe") is that one function is the only writer of billing state:

ts
// lib/stripe/sync.ts
export async function syncStripeCustomerState(customerId: string) {
  // Refetch the full subscription from Stripe and overwrite the stored state.
  // Idempotent — safe to call from out-of-order, duplicated webhooks.
}

Both the webhook handler and (optionally) your checkout-success page call it. Because it always refetches and overwrites, event ordering and duplicate delivery can't corrupt your data — which is why the handler needs no event-dedup table.

A 2026 gotcha, handled for you

Since Stripe's "Basil" API version, current_period_end moved from the subscription to the subscription item. The generated sync.ts reads it from subscription.items.data[0] — copying a pre-2025 tutorial here silently gives you undefined.

Feature gating

Features you declare in stripe.config.ts are stored in product metadata and mirrored into the customer's state during sync. Gate on them without any Stripe call in the request path:

ts
import { hasFeature, getFeature, isSubscribed } from '@/lib/stripe/customer'

if (await isSubscribed(userId)) {
  /* ... */
}
if (await hasFeature(userId, 'priority_support')) {
  /* ... */
}
const seats = await getFeature(userId, 'seats') // "5"

Features only apply while the subscription is active or trialing.

Starting checkout and the portal

Both routes are POST and read the current user via getUserId:

ts
// Start checkout for a plan — reference the STABLE lookup key, not a price id
await fetch('/api/stripe/checkout', {
  method: 'POST',
  body: JSON.stringify({ lookupKey: 'pro_monthly' }),
})
  .then((r) => r.json())
  .then(({ url }) => (location.href = url))

// Open the customer portal
await fetch('/api/stripe/portal', { method: 'POST' })
  .then((r) => r.json())
  .then(({ url }) => (location.href = url))

Wiring you must finish

  • Auth: getUserId is wired to your detected library. For Auth.js, make sure session.user.id is populated in your callbacks; if no library was detected, implement getUserId yourself (it throws until you do).
  • Middleware: Stripe must reach your webhook route unauthenticated — exclude the webhook path from any auth middleware matcher.
  • Env: set STRIPE_SECRET_KEY, your adapter's vars, and NEXT_PUBLIC_APP_URL. stripekit push fills in STRIPE_WEBHOOK_SECRET and STRIPE_PORTAL_CONFIGURATION_ID.
  • Drizzle: run a migration to create the stripe_customers table.

Released under the MIT License.