npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

nextjs-supabase-stripe

v0.3.0

Published

Stripe integration module for Next.js + Supabase — one-time payments, subscriptions, webhooks

Readme

nextjs-supabase-stripe

Stripe integration module for Next.js App Router + Supabase — one-time payments, subscriptions, webhooks, and server actions.

npm version CI License: MIT


Getting started

Add Stripe payments to your Next.js + Supabase app in under 5 minutes.

1. Install

pnpm add nextjs-supabase-stripe stripe @supabase/ssr

2. 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=xxx

3. Run the database migration

supabase migration new create_stripe_tables
supabase db push
create 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.completed

That's it. Payments, webhooks, and database sync are all wired up.

Using Claude Code? Skip all of the above — just type set up stripe and Claude handles every step automatically. See the Claude Code skill ↓


Demo app

→ Live demo · Source

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 stripe

Claude 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 DB

The 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 end

Webhooks — 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.

View the demo source


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 stripe

Claude detects what's already in place and walks through every step automatically.

What the skill does:

  1. Checks for missing dependencies and installs them
  2. Creates the Supabase migration (stripe_customers, subscriptions, orders, webhook_events) and applies it
  3. Creates app/api/webhooks/stripe/route.ts with signature verification and idempotency
  4. Scaffolds a CheckoutButton component (and billing portal button if subscriptions are enabled)
  5. Adds all required env vars to .env.local (with placeholder values to fill in)
  6. Optionally configures Slack failure notifications
  7. 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.completed

Requirements

  • Next.js 14+
  • Supabase (Auth + Postgres)
  • Node.js 18+

Installation

pnpm add nextjs-supabase-stripe stripe @supabase/ssr

Webhook 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 immediately

The 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.js

The 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