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 portalThe sync pattern
The core idea (from "How I Stay Sane Implementing Stripe") is that one function is the only writer of billing state:
// 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:
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:
// 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:
getUserIdis wired to your detected library. For Auth.js, make suresession.user.idis populated in your callbacks; if no library was detected, implementgetUserIdyourself (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, andNEXT_PUBLIC_APP_URL.stripekit pushfills inSTRIPE_WEBHOOK_SECRETandSTRIPE_PORTAL_CONFIGURATION_ID. - Drizzle: run a migration to create the
stripe_customerstable.