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.