In the companion roadmap, I argued that indie SaaS economics live or die at the unit-cost layer. This piece is the implementation half: the actual Stripe + Cloudflare Workers + Claude API code that turns the roadmap into a running, billing product.
I run four AI-focused publications on this same stack — Claude Lab, Gemini Lab, Antigravity Lab, and Rork Lab — and over time I've stepped on almost every rake an indie developer can step on with subscription billing. This guide is the consolidated result: the architecture I'd recommend to a friend launching their first paid product.
The code targets Next.js 16 (App Router) on Cloudflare Workers via OpenNext, with Stripe v15. The logic translates fine to other stacks; only the deployment glue changes.
Reference Architecture
Here is the system we're building toward:
- Authentication: NextAuth.js or Clerk (this guide uses NextAuth)
- Billing: Stripe Checkout for subscriptions, Stripe Customer Portal for self-service cancellation
- Authorization source of truth: Cloudflare KV, written by the Stripe webhook
- API execution layer: Cloudflare Workers + Anthropic SDK
- Paywall: Server middleware reads KV; non-members get blocked
KV as the single source of truth is the most important architectural decision. Querying Stripe on every page load is wrong on two axes — rate limits and latency. Update KV only when state changes (via webhook), and read only KV at runtime.
Step 1: Define Products and Prices in Stripe
Create products and prices in the Stripe Dashboard. My standard layout looks like this:
- Pro Monthly (JPY): ¥580/month, recurring, stored as
STRIPE_PRICE_PRO_JPY - Pro Monthly (USD): $5/month, stored as
STRIPE_PRICE_PRO_USD - Premium Lifetime (JPY/USD): ¥2,480 / $15 one-shot, stored as
STRIPE_PRICE_PREMIUM_*
Splitting products by currency matters. Stripe Checkout shows the product name and description, and you want those localized properly per locale. A single bilingual product invariably leaks the wrong language into checkout (see project STUMBLING_POINTS #79).
Store the price IDs in wrangler.toml. Put the actual secrets (STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET) in wrangler secret put instead.
Step 2: Implement the Checkout Endpoint
In Next.js App Router, place a POST handler at app/api/checkout/route.ts. The example below handles both subscriptions and one-shot purchases through the same endpoint:
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { getPriceIds, getProductData } from '@/config/pricing';
export const runtime = 'edge';
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
}
const { plan, locale, articleSlug } = await req.json();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2025-10-31',
});
const product = getProductData(plan, locale);
const params: Stripe.Checkout.SessionCreateParams = {
mode: plan === 'pro' ? 'subscription' : 'payment',
success_url: `${process.env.SITE_URL}/${locale}/thanks?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.SITE_URL}/${locale}/membership`,
customer_email: session.user.email,
line_items: [{
price_data: {
currency: locale === 'ja' ? 'jpy' : 'usd',
recurring: plan === 'pro' ? { interval: 'month' } : undefined,
unit_amount: product.amount,
product_data: {
name: product.name,
description: product.description,
images: [product.imageUrl],
},
},
quantity: 1,
}],
metadata: {
plan_type: plan,
user_email: session.user.email,
article_slug: articleSlug ?? '',
},
};
const checkoutSession = await stripe.checkout.sessions.create(params);
return NextResponse.json({ url: checkoutSession.url });
}Always set metadata.plan_type. The webhook needs it to distinguish a subscription start from a tip from a one-shot purchase. Misclassifying these is the single most common indie billing bug — seeing a tip accidentally grant lifetime premium access is a war story I've heard from at least four developers personally.
Step 3: Handle the Stripe Webhook
The webhook is the heart of the system. Payment success → KV write happens entirely here.
// app/api/stripe/webhook/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { getCloudflareContext } from '@opennextjs/cloudflare';
export const runtime = 'edge';
export async function POST(req: NextRequest) {
const sig = req.headers.get('stripe-signature');
const body = await req.text();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2025-10-31',
});
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body, sig!, process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (e) {
return NextResponse.json({ error: 'invalid signature' }, { status: 400 });
}
const { env } = getCloudflareContext();
const kv = env.MEMBERSHIP_KV;
// Idempotency: skip duplicates
const eventKey = `site:claudelab:webhook:${event.id}`;
const seen = await kv.get(eventKey);
if (seen) {
return NextResponse.json({ received: true, duplicate: true });
}
await kv.put(eventKey, '1', { expirationTtl: 86400 * 7 });
switch (event.type) {
case 'checkout.session.completed': {
const s = event.data.object as Stripe.Checkout.Session;
const planType = s.metadata?.plan_type;
const email = s.customer_email ?? s.metadata?.user_email;
if (!email) break;
if (planType === 'pro') {
const subscription = await stripe.subscriptions.retrieve(s.subscription as string);
await kv.put(
`site:claudelab:member:${email}`,
JSON.stringify({
plan: 'pro',
current_period_end: subscription.current_period_end,
subscription_id: subscription.id,
}),
{ expirationTtl: subscription.current_period_end - Math.floor(Date.now() / 1000) + 86400 * 3 }
);
} else if (planType === 'premium') {
await kv.put(
`site:claudelab:member:${email}`,
JSON.stringify({ plan: 'premium' }),
{ expirationTtl: 86400 * 365 * 10 }
);
} else if (planType === 'article') {
const slug = s.metadata?.article_slug;
if (slug) {
await kv.put(
`site:claudelab:article:${email}:${slug}`,
'1',
{ expirationTtl: 86400 * 365 * 10 }
);
}
}
// tips are intentionally not granted any access
break;
}
case 'customer.subscription.deleted': {
const sub = event.data.object as Stripe.Subscription;
const customer = await stripe.customers.retrieve(sub.customer as string);
if ('email' in customer && customer.email) {
await kv.delete(`site:claudelab:member:${customer.email}`);
}
break;
}
}
return NextResponse.json({ received: true });
}The TTL design matters more than it looks. Setting Pro members to "next renewal date + 3 days of buffer" creates an automatic safety valve: if a webhook is somehow lost during cancellation, KV will simply forget the user a few days later. Lifetime purchases get 10 years, which is functionally infinite.
Step 4: Read Authorization at the Paywall
Pull the membership state out of KV through a small, central helper. I keep this in lib/premium.ts:
import { getCloudflareContext } from '@opennextjs/cloudflare';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
export async function canViewPremium(): Promise<boolean> {
const session = await getServerSession(authOptions);
if (!session?.user?.email) return false;
try {
const { env } = getCloudflareContext();
const raw = await env.MEMBERSHIP_KV.get(`site:claudelab:member:${session.user.email}`);
if (!raw) return false;
const data = JSON.parse(raw);
return data.plan === 'pro' || data.plan === 'premium';
} catch (e) {
return false; // deny by default
}
}
export async function getArticleAccess(slug: string): Promise<boolean> {
const session = await getServerSession(authOptions);
if (!session?.user?.email) return false;
try {
const { env } = getCloudflareContext();
const raw = await env.MEMBERSHIP_KV.get(
`site:claudelab:article:${session.user.email}:${slug}`
);
return raw === '1';
} catch (e) {
return false; // deny by default
}
}The fallback returning false is non-negotiable. The day someone "fixes" this to return true for "robustness" is the day a KV outage opens every premium article to the public. Deny by default isn't a stylistic preference; it's a property your business needs.
Step 5: Implement Per-User Usage Caps
The hybrid plan I recommended in the roadmap (flat fee + overage) requires per-user monthly counters. Use KV again, with a month-scoped key:
const PLAN_LIMITS: Record<string, number> = {
free: 5,
pro: 100,
premium: 9999,
};
export async function checkAndIncrementUsage(
email: string,
plan: 'free' | 'pro' | 'premium'
): Promise<{ allowed: boolean; remaining: number; overage: number }> {
const { env } = getCloudflareContext();
const month = new Date().toISOString().slice(0, 7);
const key = `site:claudelab:usage:${email}:${month}`;
const raw = await env.MEMBERSHIP_KV.get(key);
const current = raw ? parseInt(raw, 10) : 0;
const limit = PLAN_LIMITS[plan];
const now = new Date();
const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 1);
const ttl = Math.floor((monthEnd.getTime() - now.getTime()) / 1000) + 86400;
await env.MEMBERSHIP_KV.put(key, String(current + 1), { expirationTtl: ttl });
if (current >= limit) {
return { allowed: plan === 'pro', remaining: 0, overage: current + 1 - limit };
}
return { allowed: true, remaining: limit - current - 1, overage: 0 };
}In a hybrid plan, Pro users who exceed the cap still get allowed: true; the overage is recorded for end-of-month billing. The full Stripe Usage Records dance is overkill for indie scale — sending users a polite end-of-month email with their overage and a one-click link to top up performs surprisingly well.
Step 6: Call the Anthropic SDK
After authorization and quota checks pass, finally call Claude:
import Anthropic from '@anthropic-ai/sdk';
export const runtime = 'edge';
export async function POST(req: Request) {
const session = await getServerSession(authOptions);
if (!session?.user?.email) return new Response('unauthorized', { status: 401 });
const isMember = await canViewPremium();
const plan = isMember ? 'pro' : 'free';
const usage = await checkAndIncrementUsage(session.user.email, plan);
if (!usage.allowed) {
return Response.json({ error: 'usage_limit_exceeded' }, { status: 429 });
}
const { prompt } = await req.json();
const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
const message = await anthropic.messages.create({
model: 'claude-sonnet-4-6',
max_tokens: 2000,
messages: [{ role: 'user', content: prompt }],
});
return Response.json({
text: message.content[0].type === 'text' ? message.content[0].text : '',
remaining: usage.remaining,
overage: usage.overage,
});
}Always set max_tokens explicitly. The classic indie disaster is "infinite max_tokens in a retry loop burns the entire month's budget in one afternoon." Set Anthropic Console budget alerts on top, not instead of, this guardrail.
Step 7: Cancellation Flows and Edge Cases
Production cancellation flows always have at least three rakes lying around.
Last-minute usage on canceled accounts. When a user clicks cancel, they should still be able to use the product through the end of their billing period. Listen for customer.subscription.updated (with cancel_at_period_end: true) to mark the upcoming cancellation, and only treat customer.subscription.deleted as the actual access removal point.
Auto-downgrade after expiration. The TTL pattern (period_end + 3 days) handles this gracefully when webhooks are reliable. For belt-and-suspenders safety, run a Cron Trigger on the first of each month that fetches recently-expired subscriptions from Stripe and reconciles KV.
Duplicate webhooks causing double charges. Stripe sometimes resends events. The event.id idempotency check shown in Step 3 isn't optional — it's the difference between a calm operations life and a string of "I got charged twice" support tickets.
Step 8: Fight Cloudflare Edge Cache Pollution
The most-overlooked indie SaaS bug: Cloudflare Workers HTML caching ignores cookies by default, so the same membership-CTA HTML gets served to paying and non-paying users alike.
The fix is two-layered. First, in cache-worker.js, bypass the cache for any request carrying a premium_token or article_purchases cookie. Second, render any membership-aware UI from a client-side fetch to /api/premium-status so the post-payment refresh sees fresh content.
addEventListener('fetch', event => {
const req = event.request;
const cookie = req.headers.get('cookie') || '';
if (cookie.includes('premium_token') || cookie.includes('article_purchases')) {
event.respondWith(fetch(req));
return;
}
event.respondWith(handleCachedRequest(event));
});Skip this and you'll get a flood of "I paid but the upgrade prompt is still there" tickets within a week (project STUMBLING_POINTS #73).
Pre-Launch Checklist
Before you flip the switch on production:
- The Stripe webhook endpoint URL points at production
STRIPE_WEBHOOK_SECRETis the live-mode secret, not the test one- KV keys are namespaced with
site:{name}:so multi-site reuse can't cross-contaminate - Monthly budget alerts are configured in Anthropic Console
- You've tested the create → cancel → resubscribe path end-to-end in Stripe test mode
- Premium features are confirmed blocked after subscription deletion
cache-worker.jscookie bypass is verified live
Hit those marks and you have a Claude-powered subscription SaaS that bills correctly from the first paying user. From here, the work is no longer architectural — it's the patient retention craft from the roadmap article.
If you get stuck in a corner, the four sites I run on this exact stack — Claude Lab, Gemini Lab, Antigravity Lab, and Rork Lab — are open as a working reference. Email me through any of their support pages and I'll help where I can.