Stripe Subscription Billing with Claude Code
Ship recurring plans end to end with Claude Code: tiered pricing, Stripe Checkout for subscriptions, proration on plan changes, the customer portal, and webhook-driven entitlement sync into Supabase.
設定をやめて、構築を始めよう。
AIオーケストレーション付きSaaSビルダーテンプレート。
Recurring revenue is the whole point of a SaaS, and Stripe subscriptions are how you collect it. This walks through the exact flow: tiered pricing in Stripe, a Checkout session for subscriptions, proration when someone changes plans, the customer portal for self-service, and a webhook that syncs entitlements into Supabase so your app knows who paid for what. Claude Code writes each piece, and you wire them into a Next.js 16 app that actually charges money.
設定をやめて、構築を始めよう。
AIオーケストレーション付きSaaSビルダーテンプレート。
The Flow You Are Actually Building
Before any code, get the shape of the system clear so Claude Code builds the right thing. There are five moving parts, and they connect in one direction: Stripe holds the money and the truth, webhooks push that truth into your database, and your app reads entitlements from the database to decide what a user can do.
Here is the loop. A user picks a plan and hits Checkout. Stripe collects the card and creates a subscription. Stripe fires a webhook at your server. Your webhook writes the plan and status into a Supabase table. Your app reads that table to gate features. When the user upgrades, downgrades, or cancels through the customer portal, Stripe fires another webhook, and the table updates again. Your app never guesses. It reads one row.
Give Claude Code that mental model in plan mode so it does not try to store card data or check Stripe on every request:
claude --plan "build Stripe subscription billing: three tiers, Checkout in subscription mode, a webhook that syncs subscription status into a Supabase 'subscriptions' table, and the customer portal for plan changes. Entitlements are read from Supabase, never from the Stripe API at request time."The Entitlements Table in Supabase
Everything hangs off one table. It maps a user to their current Stripe customer, their active subscription, the tier they are paying for, and whether that subscription is live. Row-level security makes sure a user can only ever read their own row, and a service-role client (used only in the webhook) is the only thing that writes to it.
Ask Claude Code to generate the migration. The status column mirrors Stripe's subscription statuses, and current_period_end lets you show renewal dates and handle grace periods without another API call.
-- supabase/migrations/0001_subscriptions.sql
create table public.subscriptions (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
stripe_customer_id text not null,
stripe_subscription_id text unique,
price_id text,
tier text not null default 'free',
status text not null default 'inactive',
current_period_end timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (user_id)
);
create index subscriptions_customer_idx on public.subscriptions (stripe_customer_id);
alter table public.subscriptions enable row level security;
-- Users can read only their own subscription row.
create policy "read own subscription"
on public.subscriptions for select
using (auth.uid() = user_id);
-- No client-side writes. The webhook writes with the service role,
-- which bypasses RLS, so no insert/update policy is exposed to clients.The important decision here is that there is no insert or update policy for regular users. The only writer is the webhook, running with the Supabase service role key on the server. Clients read, Stripe writes. That is what keeps entitlements honest.
Defining Tiers in Stripe
Tiers live in Stripe as Products with recurring Prices. You can create them in the dashboard, but doing it in code keeps them versioned and repeatable across environments. Each price has an ID that looks like price_1AbC..., and that ID is the link between Stripe and your app.
Have Claude Code write a one-off setup script. Run it once against your test-mode key, copy the printed price IDs into your environment, and you are done.
// scripts/setup-stripe-plans.ts
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
async function main() {
const tiers = [
{ name: "Starter", tier: "starter", amount: 900 },
{ name: "Pro", tier: "pro", amount: 2900 },
{ name: "Business", tier: "business", amount: 9900 },
];
for (const t of tiers) {
const product = await stripe.products.create({
name: `Acme ${t.name}`,
metadata: { tier: t.tier },
});
const price = await stripe.prices.create({
product: product.id,
unit_amount: t.amount,
currency: "usd",
recurring: { interval: "month" },
metadata: { tier: t.tier },
});
console.log(`${t.tier}: ${price.id}`);
}
}
main();Two details matter. First, unit_amount is in cents, so 2900 is 29.00 USD. Second, the tier name lives in the price metadata. That means your webhook can read the tier straight off the Stripe object later instead of hardcoding a lookup table, though we will keep an explicit map too for safety.
Store the printed IDs in your environment file:
# .env.local
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_APP_URL=http://localhost:3000
STRIPE_PRICE_STARTER=price_...
STRIPE_PRICE_PRO=price_...
STRIPE_PRICE_BUSINESS=price_...Checkout in Subscription Mode
Checkout is a hosted Stripe page. You do not collect card numbers, you do not touch PCI scope, you hand Stripe a price and a customer and it does the rest. The key difference from a one-time payment is mode set to subscription, which tells Stripe to create a recurring subscription instead of charging once.
This route handler runs on the server, looks up (or creates) the Stripe customer for the signed-in user, and returns a Checkout URL. Note the async cookies call and the await on nothing exotic here, just standard Next.js 16 server code.
// app/api/checkout/route.ts
import { NextResponse } from "next/server";
import Stripe from "stripe";
import { createClient } from "@/lib/supabase/server";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const { priceId } = await req.json();
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: "Not signed in" }, { status: 401 });
}
// Reuse the Stripe customer if this user already has one.
const { data: existing } = await supabase
.from("subscriptions")
.select("stripe_customer_id")
.eq("user_id", user.id)
.maybeSingle();
let customerId = existing?.stripe_customer_id;
if (!customerId) {
const customer = await stripe.customers.create({
email: user.email,
metadata: { user_id: user.id },
});
customerId = customer.id;
}
const session = await stripe.checkout.sessions.create({
mode: "subscription",
customer: customerId,
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/billing?success=1`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
// Pass user_id through so the webhook can find the row even
// before a subscription row exists.
subscription_data: { metadata: { user_id: user.id } },
});
return NextResponse.json({ url: session.url });
}Passing user_id into both the customer metadata and the subscription_data metadata is the trick that makes the webhook reliable. When Stripe fires the subscription event, the payload carries that user_id, so you never have to reverse-lookup a user from an email address.
The client side is a button that posts to this route and redirects. Because the response is just a URL, the pricing page stays a Server Component and only the button needs interactivity.
// components/checkout-button.tsx
"use client";
export function CheckoutButton({ priceId, label }: { priceId: string; label: string }) {
async function subscribe() {
const res = await fetch("/api/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ priceId }),
});
const { url } = await res.json();
if (url) window.location.href = url;
}
return (
<button
onClick={subscribe}
className="rounded-md bg-black px-4 py-2 text-white"
>
{label}
</button>
);
}The Webhook That Syncs Entitlements
This is the load-bearing piece. Stripe sends events to a route on your server, and this route translates those events into rows in your subscriptions table. It is the only place that writes tier and status, and it runs with the service-role client so it can bypass RLS.
Two things make a Stripe webhook safe. You must verify the signature so nobody can forge a "you got paid" event, and you must read the raw request body, because Stripe signs the exact bytes it sent. In a Next.js 16 route handler, await req.text() gives you those bytes untouched.
// app/api/webhooks/stripe/route.ts
import { NextResponse } from "next/server";
import Stripe from "stripe";
import { createServiceClient } from "@/lib/supabase/service";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
// Explicit map from Stripe price to your app tier. The metadata on
// the price is a backup; this map is the source your code trusts.
const PRICE_TO_TIER: Record<string, string> = {
[process.env.STRIPE_PRICE_STARTER!]: "starter",
[process.env.STRIPE_PRICE_PRO!]: "pro",
[process.env.STRIPE_PRICE_BUSINESS!]: "business",
};
export async function POST(req: Request) {
const body = await req.text();
const sig = req.headers.get("stripe-signature");
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, sig!, webhookSecret);
} catch (err) {
return NextResponse.json({ error: "Bad signature" }, { status: 400 });
}
const supabase = createServiceClient();
// A helper that upserts the row from any subscription object.
async function syncSubscription(sub: Stripe.Subscription) {
const userId = sub.metadata.user_id;
const priceId = sub.items.data[0]?.price.id;
const tier = priceId ? PRICE_TO_TIER[priceId] ?? "free" : "free";
const active = sub.status === "active" || sub.status === "trialing";
await supabase.from("subscriptions").upsert(
{
user_id: userId,
stripe_customer_id: sub.customer as string,
stripe_subscription_id: sub.id,
price_id: priceId,
tier: active ? tier : "free",
status: sub.status,
current_period_end: new Date(sub.items.data[0].current_period_end * 1000).toISOString(),
updated_at: new Date().toISOString(),
},
{ onConflict: "user_id" },
);
}
switch (event.type) {
case "customer.subscription.created":
case "customer.subscription.updated":
case "customer.subscription.deleted": {
await syncSubscription(event.data.object as Stripe.Subscription);
break;
}
case "checkout.session.completed": {
// Fetch the fresh subscription so we have full item and period data.
const session = event.data.object as Stripe.Checkout.Session;
if (session.subscription) {
const sub = await stripe.subscriptions.retrieve(session.subscription as string);
await syncSubscription(sub);
}
break;
}
default:
break;
}
return NextResponse.json({ received: true });
}A few things worth calling out. The syncSubscription helper is deliberately the single path that writes a row, so created, updated, deleted, and the initial checkout all funnel through the same logic. When a subscription is canceled, Stripe sends customer.subscription.deleted with a status of canceled, and because active is false, the row drops back to the free tier. And the upsert keys on user_id, so a user always has exactly one row no matter how many times they change plans.
Point Stripe at this route. In development, the Stripe CLI forwards live events to your local server and prints the signing secret to paste into your environment:
stripe listen --forward-to localhost:3000/api/webhooks/stripeIn production, add the endpoint in the Stripe dashboard under Developers, Webhooks, and subscribe it to checkout.session.completed, customer.subscription.created, customer.subscription.updated, and customer.subscription.deleted.
Reading Entitlements to Gate Features
Now the payoff. Anywhere in your app that needs to know whether a user can access a paid feature, you read the subscriptions table, not Stripe. Because RLS scopes the query to the current user, a Server Component can ask the question in one line and trust the answer.
This helper returns the user's tier and whether their subscription is active. Gate a page or a Server Action on it and you are done.
// lib/entitlements.ts
import { createClient } from "@/lib/supabase/server";
const TIER_RANK: Record<string, number> = {
free: 0,
starter: 1,
pro: 2,
business: 3,
};
export async function getEntitlement() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) return { tier: "free", active: false };
const { data } = await supabase
.from("subscriptions")
.select("tier, status")
.eq("user_id", user.id)
.maybeSingle();
const active = data?.status === "active" || data?.status === "trialing";
return { tier: active ? data!.tier : "free", active };
}
export async function requireTier(minTier: keyof typeof TIER_RANK) {
const { tier } = await getEntitlement();
return TIER_RANK[tier] >= TIER_RANK[minTier];
}Using it inside a Server Component is direct. The page renders the gated content only when the user has cleared the tier bar.
// app/reports/page.tsx
import { requireTier } from "@/lib/entitlements";
import { redirect } from "next/navigation";
export default async function ReportsPage() {
const allowed = await requireTier("pro");
if (!allowed) redirect("/pricing");
return <main className="p-8">Pro reports go here.</main>;
}The TIER_RANK map turns tier gating into a numeric comparison, so a Business user automatically clears a Pro gate without you listing every tier by hand. That small design choice saves you from a wall of string checks as plans multiply.
The Customer Portal for Upgrades, Downgrades, and Cancellations
You could build upgrade and cancel buttons by hand, but Stripe already ships a hosted portal that does all of it: change plan, update card, download invoices, cancel. You send the user there, they make changes, Stripe fires the same subscription webhooks you already handle, and your table stays in sync. It is the highest-leverage piece in the whole system because it is the piece you do not have to build.
This route creates a portal session for the current user's Stripe customer and returns the URL. It looks almost identical to the Checkout route, which is the point.
// app/api/portal/route.ts
import { NextResponse } from "next/server";
import Stripe from "stripe";
import { createClient } from "@/lib/supabase/server";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: "Not signed in" }, { status: 401 });
}
const { data } = await supabase
.from("subscriptions")
.select("stripe_customer_id")
.eq("user_id", user.id)
.maybeSingle();
if (!data?.stripe_customer_id) {
return NextResponse.json({ error: "No customer" }, { status: 400 });
}
const session = await stripe.billingPortal.sessions.create({
customer: data.stripe_customer_id,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/billing`,
});
return NextResponse.json({ url: session.url });
}Before the portal will let customers switch plans, enable it once in the Stripe dashboard under Settings, Billing, Customer portal. Turn on "Customers can switch plans" and add your three prices to the allowed list. Set whether downgrades apply immediately or at period end. That configuration lives in Stripe, not your code, which means you tune billing behavior without a deploy.
Proration, Handled for You
Proration is the thing people expect to be hard. It is the math of "you switched from a 9 USD plan to a 29 USD plan on day 15, so you owe the difference for the back half of the month." Stripe does this automatically. When a customer changes plans in the portal, Stripe charges the prorated difference immediately on an upgrade, and on a downgrade it issues a credit that lands on the next invoice instead of a refund.
Because the portal handles plan changes, you never write proration code. But if you ever change a plan from your own backend, for example bumping a user during a promo, the API respects the same rules. The proration_behavior option controls it, and the default of create_prorations is almost always what you want.
// Example: change a plan from your own server (the portal usually does this).
await stripe.subscriptions.update(subscriptionId, {
items: [{ id: subscriptionItemId, price: newPriceId }],
proration_behavior: "create_prorations", // default; use "none" to skip
});Whichever path triggers the change, portal or API, the result is a customer.subscription.updated webhook, and your syncSubscription helper writes the new tier into Supabase. The billing math and the entitlement update stay decoupled, which is exactly how you want it.
Quality Gates Before You Ship
Money code fails loudly and expensively, so run the checks before every commit. Type errors in a webhook handler are the ones that bite hardest, because they only surface when a real event arrives.
Ask Claude Code to run both and fix what breaks:
claude "run tsc --noEmit and fix any type errors in the Stripe routes, then confirm the build passes"npx tsc --noEmit
npm run buildThen test the real flow, not just the types. Use a Stripe test card, subscribe, and watch the row appear in Supabase. Switch plans in the portal and watch the tier change. Cancel and watch it drop to free. The stripe listen CLI plus a test card exercises the entire loop locally before a single real card touches it.
Where the Code Kit Fits
Claude Code writes every file above, and if you feed it the plan in this post it will get most of it right on the first pass. What it does not do on its own is enforce that the webhook, the entitlements helper, and the gating all stay consistent as your app grows. That is the gap the $29 Code Kit fills: a harness on top of Claude Code that plans a feature, builds it, evaluates the output, tests it against a real browser, and runs the quality gates (zero type errors, zero lint errors, a clean build) before anything ships. It is a one-time 29 USD purchase, no subscription, and it runs on the same Claude Code you already have (which needs a paid Anthropic plan). It does not replace the work in this guide, it makes that work repeatable across every feature you add after billing is live.
設定をやめて、構築を始めよう。
AIオーケストレーション付きSaaSビルダーテンプレート。
Posted by @speedy_devv
設定をやめて、構築を始めよう。
AIオーケストレーション付きSaaSビルダーテンプレート。
Vibe Coding's 90-Day Reckoning: The Technical Debt Nobody Warns You About
Vibe coding gets you to a demo, not to month 3. Here is why AI-generated code accumulates technical debt, what it actually costs, and how to keep the speed without the wall.
Multi-Tenant SaaS
Add organizations, seats, and invite flows to a Next.js SaaS: workspace-scoped tables, Supabase RLS for tenant isolation, team invites via Resend, and a member and role switcher.