nextjs-supabase-stripe
v0.3.0
Published
Stripe integration module for Next.js + Supabase — one-time payments, subscriptions, webhooks
Maintainers
Readme
nextjs-supabase-stripe
Stripe integration module for Next.js App Router + Supabase — one-time payments, subscriptions, webhooks, and server actions.
Getting started
Add Stripe payments to your Next.js + Supabase app in under 5 minutes.
1. Install
pnpm add nextjs-supabase-stripe stripe @supabase/ssr2. Add env vars
# .env.local
STRIPE_SECRET_KEY=sk_test_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_xxx
NEXT_PUBLIC_APP_URL=http://localhost:3000
NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=xxx
SUPABASE_SERVICE_ROLE_KEY=xxx3. Run the database migration
supabase migration new create_stripe_tables
supabase db pushcreate table stripe_customers (
user_id uuid primary key references auth.users(id) on delete cascade,
stripe_customer_id text unique not null,
created_at timestamptz default now()
);
create table subscriptions (
id uuid primary key default gen_random_uuid(),
user_id uuid references auth.users(id) on delete cascade not null,
stripe_subscription_id text unique not null,
stripe_price_id text not null,
status text not null,
current_period_start timestamptz,
current_period_end timestamptz,
cancel_at_period_end boolean default false,
cancel_at timestamptz,
created_at timestamptz default now()
);
create table orders (
id uuid primary key default gen_random_uuid(),
user_id uuid references auth.users(id) on delete set null,
stripe_session_id text unique not null,
amount integer not null,
currency text not null,
status text not null check (status in ('pending', 'paid', 'failed')),
created_at timestamptz default now()
);
create table webhook_events (
id text primary key,
type text not null,
created_at timestamptz default now()
);
alter table stripe_customers enable row level security;
alter table subscriptions enable row level security;
alter table orders enable row level security;
alter table webhook_events enable row level security;
create policy "users_read_own_stripe_customer" on stripe_customers
for select to authenticated using (auth.uid() = user_id);
create policy "users_read_own_subscriptions" on subscriptions
for select to authenticated using (auth.uid() = user_id);
create policy "users_read_own_orders" on orders
for select to authenticated using (auth.uid() = user_id);4. Mount the webhook route
// app/api/webhooks/stripe/route.ts
import { createWebhookHandler } from 'nextjs-supabase-stripe/webhooks'
export const POST = createWebhookHandler()5. Add a checkout button
Create a local app/actions.ts wrapper first (catches UnauthorizedError → redirect to /login):
// app/actions.ts
'use server'
import { createCheckout as _createCheckout, UnauthorizedError } from 'nextjs-supabase-stripe/actions'
import { redirect } from 'next/navigation'
export async function createCheckout(priceId: string, mode: 'payment' | 'subscription') {
try {
await _createCheckout(priceId, mode)
} catch (e: any) {
if (e?.digest?.startsWith('NEXT_REDIRECT')) throw e
if (e instanceof UnauthorizedError) redirect('/login')
throw e
}
}Then create the button as a server component (no 'use client') using .bind():
// components/checkout-button.tsx ← no 'use client'
import { createCheckout } from '@/app/actions'
export const CheckoutButton = ({ priceId, mode }: {
priceId: string
mode: 'payment' | 'subscription'
}) => (
<form action={createCheckout.bind(null, priceId, mode)}>
<button type="submit">Subscribe</button>
</form>
)Why
.bind()and not() => createCheckout(...)? Wrapping a server action in an arrow function inside a'use client'component loses the server action reference — the form silently does nothing. Using.bind()on a server component is required.
6. Test it locally
stripe listen --forward-to localhost:3000/api/webhooks/stripe
stripe trigger checkout.session.completedThat's it. Payments, webhooks, and database sync are all wired up.
Using Claude Code? Skip all of the above — just type
set up stripeand Claude handles every step automatically. See the Claude Code skill ↓
Demo app
A working demo — minimal Next.js + Supabase SaaS with a pricing page, Stripe Checkout, a protected dashboard, and subscription management.
The entire demo was built using this package and Claude Code. Here's exactly how:
Setup — set up stripe
After scaffolding a blank Next.js + Supabase project, the Claude Code skill that ships inside this package was invoked with a single command:
set up stripeClaude ran preflight checks, created the Supabase migration, wrote the webhook route, and added all required env vars to .env.local — no copy-pasting required.
Pricing page — createCheckout
Each plan card has a checkout button that calls createCheckout as a server action. Clicking it redirects directly to Stripe Checkout with the correct price ID and user metadata attached.
The button is a server component using .bind() — this is required. Wrapping a server action in an arrow function inside a 'use client' component loses the server action reference and the form silently does nothing.
// app/actions.ts — local wrapper (catches UnauthorizedError → redirect to /login)
'use server'
import { createCheckout as _createCheckout, UnauthorizedError } from 'nextjs-supabase-stripe/actions'
import { redirect } from 'next/navigation'
export async function createCheckout(priceId: string, mode: 'payment' | 'subscription') {
try {
await _createCheckout(priceId, mode)
} catch (e: any) {
if (e?.digest?.startsWith('NEXT_REDIRECT')) throw e
if (e instanceof UnauthorizedError) redirect('/login')
throw e
}
}
// demo/app/pricing/checkout-button.tsx — server component, no 'use client'
import { createCheckout } from '../actions'
export default function CheckoutButton({ priceId }: { priceId: string }) {
return (
<form action={createCheckout.bind(null, priceId, 'subscription')}>
<button type="submit">Get started</button>
</form>
)
}Dashboard — requireActiveSubscription + getSubscription
The dashboard is a server component. Two lines handle auth and data — no middleware, no custom guards:
// demo/app/dashboard/page.tsx
await requireActiveSubscription() // redirects to /pricing if no active sub
const sub = await getSubscription() // typed subscription row from DBThe page renders the plan status, current period end, and cancel state directly from the sub object.
Billing portal — getBillingPortal
A single server action wires up the "Manage billing" button. Stripe handles the rest:
// demo/app/dashboard/portal-button.tsx
import { getBillingPortal } from 'nextjs-supabase-stripe/actions'
export default function PortalButton() {
return (
<form action={getBillingPortal}>
<button type="submit">Manage billing</button>
</form>
)
}Cancel — cancelSubscription
The cancel button calls cancelSubscription() which sets cancel_at_period_end: true on the Stripe subscription. The DB is updated automatically when Stripe fires customer.subscription.updated — no extra code needed:
import { cancelSubscription } from 'nextjs-supabase-stripe/actions'
await cancelSubscription() // soft cancel — access until period endWebhooks — createWebhookHandler
The webhook route is one line. Signature verification, idempotency, and all event handlers are built in:
// demo/app/api/webhooks/stripe/route.ts
import { createWebhookHandler } from 'nextjs-supabase-stripe/webhooks'
export const POST = createWebhookHandler()Result
The complete demo — 7 routes, auth, checkout, dashboard, and webhooks — was built in a single session with Claude Code. The package handled every Stripe interaction; the demo only needed to build the UI around it.
Claude Code setup skill
This package ships a Claude Code skill that automates the full integration — migration, webhook route, env vars, and more. No copy-pasting required.
How to use it
After installing the package, open Claude Code in your project and type:
set up stripeClaude detects what's already in place and walks through every step automatically.
What the skill does:
- Checks for missing dependencies and installs them
- Creates the Supabase migration (
stripe_customers,subscriptions,orders,webhook_events) and applies it - Creates
app/api/webhooks/stripe/route.tswith signature verification and idempotency - Scaffolds a
CheckoutButtoncomponent (and billing portal button if subscriptions are enabled) - Adds all required env vars to
.env.local(with placeholder values to fill in) - Optionally configures Slack failure notifications
- Warns you to run the backfill script if existing users are present
The skill file (SKILL.md) ships inside the package — Claude Code reads it automatically from node_modules/nextjs-supabase-stripe/SKILL.md. You get updated instructions with every version you install.
Example session
You: set up stripe
Claude: Running preflight checks...
✓ nextjs-supabase-stripe installed
✓ stripe installed
✓ @supabase/ssr installed
✗ Supabase migration not found
Creating migration...
Writing supabase/migrations/20260101000000_create_stripe_tables.sql
Running: supabase db push
Creating webhook route...
Writing app/api/webhooks/stripe/route.ts
Adding env vars to .env.local...
STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
Done. Next steps:
1. Fill in the placeholder values in .env.local
2. Run: stripe listen --forward-to localhost:3000/api/webhooks/stripe
3. Test: stripe trigger checkout.session.completedRequirements
- Next.js 14+
- Supabase (Auth + Postgres)
- Node.js 18+
Installation
pnpm add nextjs-supabase-stripe stripe @supabase/ssrWebhook route
// app/api/webhooks/stripe/route.ts
import { createWebhookHandler } from 'nextjs-supabase-stripe/webhooks'
export const POST = createWebhookHandler()With optional Slack notifications on failure:
export const POST = createWebhookHandler({
slack: {
webhookUrl: process.env.SLACK_WEBHOOK_URL!,
channel: '#payments-alerts',
},
})Server actions
Checkout button
Use a server component with .bind() — do not use 'use client' with an arrow function, it will silently break the form.
// components/checkout-button.tsx ← no 'use client'
import { createCheckout } from '@/app/actions' // local wrapper, see Getting started step 5
export const CheckoutButton = ({ priceId, mode }: {
priceId: string
mode: 'payment' | 'subscription'
}) => (
<form action={createCheckout.bind(null, priceId, mode)}>
<button type="submit">Checkout</button>
</form>
)Billing portal
// components/billing-portal-button.tsx ← no 'use client'
import { getBillingPortal } from 'nextjs-supabase-stripe/actions'
export const BillingPortalButton = () => (
<form action={getBillingPortal}>
<button type="submit">Manage Subscription</button>
</form>
)Guard a page
import { requireActiveSubscription } from 'nextjs-supabase-stripe/actions'
export default async function DashboardPage() {
await requireActiveSubscription() // redirects to /pricing if not active
return <div>...</div>
}Check subscription status
import { getSubscription } from 'nextjs-supabase-stripe/actions'
const subscription = await getSubscription() // null for anonymous or no subscription
if (subscription?.status === 'active' || subscription?.status === 'trialing') {
// has access
}Cancel a subscription
import { cancelSubscription } from 'nextjs-supabase-stripe/actions'
// Cancel at period end (default) — user keeps access until billing period ends
await cancelSubscription()
// Cancel immediately
await cancelSubscription(true)The DB is updated automatically via customer.subscription.updated / customer.subscription.deleted webhooks.
Upgrade or downgrade
import { changeSubscription } from 'nextjs-supabase-stripe/actions'
await changeSubscription('price_new_plan_id')
// Control proration
await changeSubscription('price_new_plan_id', 'none') // no proration
await changeSubscription('price_new_plan_id', 'always_invoice') // invoice immediatelyThe DB is updated automatically via customer.subscription.updated webhook.
TypeScript types
import type { Subscription, Database } from 'nextjs-supabase-stripe/types'Subscription is derived directly from the Database schema so it stays in sync with your table.
Anonymous user support
| Action | Anonymous |
|---|---|
| createCheckout('payment') | Allowed — order recorded with user_id = null |
| createCheckout('subscription') | Throws UnauthorizedError |
| getBillingPortal() | Throws UnauthorizedError |
| getSubscription() | Returns null |
| requireActiveSubscription() | Redirects to /pricing |
| cancelSubscription() | Throws UnauthorizedError |
| changeSubscription(priceId) | Throws UnauthorizedError |
Testing
import { buildWebhookRequest, stripeFixtures } from 'nextjs-supabase-stripe/testing'Build a signed webhook request to pass directly to your route handler in tests:
const req = buildWebhookRequest(
'checkout.session.completed',
stripeFixtures.checkoutSessionCompleted({ mode: 'subscription', userId: 'user-1' }),
{ secret: process.env.STRIPE_WEBHOOK_SECRET! }
)
const res = await POST(req)
expect(res.status).toBe(200)Available fixtures: checkoutSessionCompleted, subscription, invoice. All are shaped for Stripe API version 2026-05-27.dahlia.
Existing users backfill
If users already had Stripe subscriptions before you installed this package, sync them into the stripe_customers table:
# Preview first — makes no writes, only reports what it would do
node node_modules/nextjs-supabase-stripe/dist/scripts/backfill.js --dry-run
# Then run for real
node node_modules/nextjs-supabase-stripe/dist/scripts/backfill.jsThe script looks up each user by email in Stripe and records the match — it does not create new Stripe customers. Always run against staging first.
It prints a summary and exits non-zero if anything failed:
Backfill complete.
Total users checked: 412
Matched: 38
Already synced: 9
No email: 2
No Stripe customer: 360
Ambiguous: 2
Failed: 1
Ambiguous — multiple Stripe customers share this email, review manually:
user 8f2a... ([email protected]): cus_abc, cus_def
Failed:
user 3c91...: stripe_customers insert failed: ...Ambiguous means more than one Stripe customer shares that user's email — the script never guesses
which one to link; review those manually in the Stripe dashboard. Failed covers real errors (a
database or Stripe API failure), never a user who simply has no matching Stripe customer.
License
MIT
