RORK LABJP
RORKMAX — Rork Max generates pure Swift instead of React Native, enabling true native apps across iPhone, iPad, Watch, TV, Vision Pro, and iMessageAPPLE — Rork's 2026 direction has a clear theme of native empowerment across the Apple ecosystemEXPO — Standard builds run on React Native and Expo, so you're left with a real project structure and code you can keep working onFUNDING — Rork recently raised $15M and now sees over 743,000 monthly visits with 85% growthPRICING — Rork is free to start, with paid plans from $25/month and Rork Max at $200/monthCROSS — Rork builds iOS, Android, and web from a single prompt, finished off with a bit of follow-up tweakingRORKMAX — Rork Max generates pure Swift instead of React Native, enabling true native apps across iPhone, iPad, Watch, TV, Vision Pro, and iMessageAPPLE — Rork's 2026 direction has a clear theme of native empowerment across the Apple ecosystemEXPO — Standard builds run on React Native and Expo, so you're left with a real project structure and code you can keep working onFUNDING — Rork recently raised $15M and now sees over 743,000 monthly visits with 85% growthPRICING — Rork is free to start, with paid plans from $25/month and Rork Max at $200/monthCROSS — Rork builds iOS, Android, and web from a single prompt, finished off with a bit of follow-up tweaking
Articles/Business
Business/2026-05-06Advanced

Building a Complete Stripe SaaS Payment System with Rork: Next.js × Cloudflare Workers × Supabase

A complete guide to implementing Stripe subscriptions, one-time purchases, and webhooks in a Rork-generated Next.js app. Build a production-grade SaaS billing stack with Cloudflare Workers and Supabase from scratch.

rork58stripe3saasnextjscloudflare-workers2supabase3subscription28monetization47

Building an app with Rork is one thing. Building the infrastructure to charge for it is another. Stripe makes billing manageable, but wiring it into a Next.js + Cloudflare Workers setup requires getting a few things right that aren't obvious from the docs.

This is the complete implementation — Checkout sessions, Webhooks, Supabase state management, and Customer Portal — with production-ready code for each layer.

Architecture Overview

Rork Max (Next.js output)
    ├── Frontend
    │   ├── /membership — Pricing page
    │   ├── /api/checkout — Create Stripe Checkout session
    │   ├── /api/webhook — Receive Stripe Webhook events
    │   └── /api/verify-session — Confirm purchase after redirect
    │
    ├── Supabase
    │   ├── subscriptions — Active subscription state
    │   ├── purchases — One-time article purchases
    │   └── payment_failures — Failed payment log
    │
    └── Stripe
        ├── Products + Prices
        ├── Checkout Sessions
        └── Customer Portal

Step 1: Stripe Products and Prices

Set up your products with the Stripe CLI during development:

# Monthly subscription (Pro)
stripe prices create \
  --currency=usd \
  --unit-amount=500 \
  --recurring[interval]=month \
  --product-data[name]="Pro Plan" \
  --lookup-key=pro_monthly_usd
 
# Annual subscription (20% off monthly)
stripe prices create \
  --currency=usd \
  --unit-amount=4800 \
  --recurring[interval]=year \
  --product-data[name]="Pro Plan" \
  --lookup-key=pro_yearly_usd
 
# One-time premium article purchase
stripe prices create \
  --currency=usd \
  --unit-amount=175 \
  --product-data[name]="Premium Article" \
  --lookup-key=article_single_usd

Store the resulting Price IDs in environment variables:

// src/config/pricing.ts
export const STRIPE_PRICES = {
  pro_monthly: process.env.STRIPE_PRICE_PRO_MONTHLY!,
  pro_yearly:  process.env.STRIPE_PRICE_PRO_YEARLY!,
} as const;
 
export const DISPLAY_PRICES = {
  pro_monthly: { jpy: 580,  usd: 5   },
  pro_yearly:  { jpy: 5568, usd: 48  },
  article:     { jpy: 250,  usd: 1.75 },
} as const;

Step 2: Checkout Session API

// app/api/checkout/route.ts
import Stripe from "stripe";
import { NextRequest, NextResponse } from "next/server";
import { STRIPE_PRICES } from "@/config/pricing";
 
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2025-04-30",
});
 
export async function POST(request: NextRequest) {
  const body = await request.json();
  const { plan_type, locale, article_slug, user_email } = body;
 
  const VALID_PLAN_TYPES = ["pro_monthly", "pro_yearly", "article"] as const;
  type PlanType = typeof VALID_PLAN_TYPES[number];
 
  if (!VALID_PLAN_TYPES.includes(plan_type as PlanType)) {
    return NextResponse.json({ error: "Invalid plan_type" }, { status: 400 });
  }
 
  // Find or create Stripe Customer
  let customerId: string | undefined;
  if (user_email) {
    const existing = await stripe.customers.list({ email: user_email, limit: 1 });
    customerId = existing.data[0]?.id
      ?? (await stripe.customers.create({ email: user_email })).id;
  }
 
  const baseUrl = process.env.NEXT_PUBLIC_SITE_URL!;
  const isSubscription = plan_type === "pro_monthly" || plan_type === "pro_yearly";
 
  const lineItems = isSubscription
    ? [{ price: STRIPE_PRICES[plan_type as "pro_monthly" | "pro_yearly"], quantity: 1 }]
    : [{
        price_data: {
          currency: locale === "ja" ? "jpy" : "usd",
          product_data: {
            name: locale === "ja" ? "プレミアム記事" : "Premium Article",
            description: locale === "ja"
              ? "単記事の永久アクセス権"
              : "Lifetime access to this premium article",
            images: [`${baseUrl}/images/stripe-product.png`],
          },
          unit_amount: locale === "ja" ? 250 : 175,
        },
        quantity: 1,
      }];
 
  const session = await stripe.checkout.sessions.create({
    customer: customerId,
    mode: isSubscription ? "subscription" : "payment",
    line_items: lineItems,
    success_url: `${baseUrl}/${locale}/verify-session?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${baseUrl}/${locale}/membership`,
    metadata: {
      plan_type,
      article_slug: article_slug ?? "",
      locale,
    },
  });
 
  return NextResponse.json({ url: session.url });
}

Step 3: Webhook Handler

This is where subscription state gets persisted to Supabase. The webhook must handle both the initial checkout completion and ongoing subscription lifecycle events:

// app/api/webhook/route.ts
import Stripe from "stripe";
import { NextRequest, NextResponse } from "next/server";
import { createClient } from "@supabase/supabase-js";
 
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2025-04-30",
});
 
// Required for Cloudflare Workers / Edge Runtime
export const runtime = "edge";
 
export async function POST(request: NextRequest) {
  const body = await request.text();
  const sig = request.headers.get("stripe-signature");
 
  if (!sig) return NextResponse.json({ error: "Missing signature" }, { status: 400 });
 
  let event: Stripe.Event;
  try {
    // Use async version — synchronous constructEvent doesn't work in Edge Runtime
    event = await stripe.webhooks.constructEventAsync(
      body,
      sig,
      process.env.STRIPE_WEBHOOK_SECRET!,
    );
  } catch (err) {
    console.error("Webhook verification failed:", err);
    return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
  }
 
  const supabase = createClient(
    process.env.SUPABASE_URL!,
    process.env.SUPABASE_SERVICE_ROLE_KEY!,
  );
 
  switch (event.type) {
    case "checkout.session.completed":
      await handleCheckoutCompleted(event.data.object as Stripe.CheckoutSession, supabase, stripe);
      break;
    case "customer.subscription.updated":
      await handleSubscriptionUpdated(event.data.object as Stripe.Subscription, supabase);
      break;
    case "customer.subscription.deleted":
      await handleSubscriptionDeleted(event.data.object as Stripe.Subscription, supabase);
      break;
    case "invoice.payment_failed":
      await handlePaymentFailed(event.data.object as Stripe.Invoice, supabase);
      break;
  }
 
  return NextResponse.json({ received: true });
}
 
async function handleCheckoutCompleted(
  session: Stripe.CheckoutSession,
  supabase: ReturnType<typeof createClient>,
  stripe: Stripe,
) {
  const { plan_type, article_slug } = session.metadata ?? {};
  const email = session.customer_email ?? session.customer_details?.email;
  if (!email) return;
 
  if (plan_type === "pro_monthly" || plan_type === "pro_yearly") {
    const sub = await stripe.subscriptions.retrieve(session.subscription as string);
 
    await supabase.from("subscriptions").upsert({
      email,
      stripe_customer_id: session.customer as string,
      stripe_subscription_id: sub.id,
      plan_type,
      status: sub.status,
      current_period_start: new Date(sub.current_period_start * 1000).toISOString(),
      current_period_end:   new Date(sub.current_period_end * 1000).toISOString(),
      cancel_at_period_end: sub.cancel_at_period_end,
      updated_at: new Date().toISOString(),
    }, { onConflict: "email" });
 
  } else if (plan_type === "article" && article_slug) {
    await supabase.from("purchases").insert({
      email,
      article_slug,
      stripe_session_id: session.id,
      purchased_at: new Date().toISOString(),
    });
  }
}
 
async function handleSubscriptionUpdated(
  sub: Stripe.Subscription,
  supabase: ReturnType<typeof createClient>,
) {
  await supabase
    .from("subscriptions")
    .update({
      status: sub.status,
      current_period_end:   new Date(sub.current_period_end * 1000).toISOString(),
      cancel_at_period_end: sub.cancel_at_period_end,
      updated_at: new Date().toISOString(),
    })
    .eq("stripe_subscription_id", sub.id);
}
 
async function handleSubscriptionDeleted(
  sub: Stripe.Subscription,
  supabase: ReturnType<typeof createClient>,
) {
  await supabase
    .from("subscriptions")
    .update({ status: "canceled", cancel_at_period_end: false, updated_at: new Date().toISOString() })
    .eq("stripe_subscription_id", sub.id);
}
 
async function handlePaymentFailed(
  invoice: Stripe.Invoice,
  supabase: ReturnType<typeof createClient>,
) {
  if (!invoice.customer_email) return;
  await supabase.from("payment_failures").insert({
    email: invoice.customer_email,
    stripe_invoice_id: invoice.id,
    amount: invoice.amount_due,
    failed_at: new Date().toISOString(),
  });
}

Step 4: Subscription Status Checking

// src/lib/subscription.ts
import { createClient } from "@supabase/supabase-js";
 
export type SubscriptionStatus = {
  is_pro: boolean;
  plan_type: string | null;
  expires_at: string | null;
  cancel_at_period_end: boolean;
};
 
export async function getSubscriptionStatus(email: string): Promise<SubscriptionStatus> {
  const supabase = createClient(
    process.env.SUPABASE_URL!,
    process.env.SUPABASE_SERVICE_ROLE_KEY!,
  );
 
  const { data } = await supabase
    .from("subscriptions")
    .select("plan_type, status, current_period_end, cancel_at_period_end")
    .eq("email", email)
    .single();
 
  const isActive = data?.status === "active" || data?.status === "trialing";
  const notExpired = data?.current_period_end
    ? new Date(data.current_period_end) > new Date()
    : false;
 
  return {
    is_pro: isActive && notExpired,
    plan_type: data?.plan_type ?? null,
    expires_at: data?.current_period_end ?? null,
    cancel_at_period_end: data?.cancel_at_period_end ?? false,
  };
}
 
export async function getArticleAccess(email: string, slug: string): Promise<boolean> {
  const supabase = createClient(
    process.env.SUPABASE_URL!,
    process.env.SUPABASE_SERVICE_ROLE_KEY!,
  );
 
  const { data } = await supabase
    .from("purchases")
    .select("id")
    .eq("email", email)
    .eq("article_slug", slug)
    .single();
 
  return data !== null;
}

Step 5: Customer Portal

Let Stripe handle plan changes, cancellations, and payment method updates through their hosted portal:

// app/api/customer-portal/route.ts
import Stripe from "stripe";
import { NextRequest, NextResponse } from "next/server";
 
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2025-04-30",
});
 
export async function POST(request: NextRequest) {
  const { customer_id, locale } = await request.json();
 
  const session = await stripe.billingPortal.sessions.create({
    customer: customer_id,
    return_url: `${process.env.NEXT_PUBLIC_SITE_URL}/${locale}/membership`,
  });
 
  return NextResponse.json({ url: session.url });
}

This eliminates the need to build cancellation and plan change UI yourself. Stripe's portal handles it, and your webhook catches the resulting events to update Supabase.

Supabase Schema

-- subscriptions
CREATE TABLE subscriptions (
  id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
  email text UNIQUE NOT NULL,
  stripe_customer_id text,
  stripe_subscription_id text UNIQUE,
  plan_type text,
  status text,
  current_period_start timestamptz,
  current_period_end timestamptz,
  cancel_at_period_end boolean DEFAULT false,
  created_at timestamptz DEFAULT now(),
  updated_at timestamptz DEFAULT now()
);
 
-- purchases (one-time articles)
CREATE TABLE purchases (
  id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
  email text NOT NULL,
  article_slug text NOT NULL,
  stripe_session_id text UNIQUE,
  purchased_at timestamptz DEFAULT now(),
  UNIQUE(email, article_slug)
);
 
-- payment_failures
CREATE TABLE payment_failures (
  id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
  email text,
  stripe_invoice_id text,
  amount integer,
  failed_at timestamptz DEFAULT now()
);
 
-- Enable RLS on all tables
ALTER TABLE subscriptions ENABLE ROW LEVEL SECURITY;
ALTER TABLE purchases ENABLE ROW LEVEL SECURITY;
 
-- Only service role has write access (app uses service role key server-side)
CREATE POLICY "Service role only" ON subscriptions FOR ALL USING (auth.role() = 'service_role');
CREATE POLICY "Service role only" ON purchases FOR ALL USING (auth.role() = 'service_role');

Cloudflare Workers: Key Differences from Node.js

Two things that differ specifically in the Cloudflare Workers / Edge Runtime environment:

Use constructEventAsync for webhook verification. The synchronous stripe.webhooks.constructEvent() relies on Node.js crypto APIs that aren't available in Edge Runtime. Always use the async version.

Set export const runtime = "edge" on the webhook route. Without this, Next.js will try to run it in Node.js mode, which creates issues in the Workers environment.

# wrangler.toml or Cloudflare Dashboard — required variables
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PRICE_PRO_MONTHLY=price_...
STRIPE_PRICE_PRO_YEARLY=price_...
SUPABASE_URL=https://xxxxx.supabase.co
SUPABASE_SERVICE_ROLE_KEY=eyJ...
NEXT_PUBLIC_SITE_URL=https://yoursite.com

Combining Web SaaS with Mobile IAP

If you're running both a web app and a mobile app (common for Rork developers), use email address as the shared key between the two billing systems. A user who subscribes via the web should get access in the mobile app, and vice versa.

The pattern: authenticate the user by email in both apps, then check both subscriptions (Stripe web) and RevenueCat (mobile IAP) for active access. Whichever is active, grant access. This unified check can live in a shared edge function or a Supabase RPC call.

The implementation takes a weekend to build and handles everything from free trial through cancellation to win-back offers. It's the foundation that makes everything else — pricing experiments, churn analysis, revenue forecasting — possible to build on top of.

I hope this gives you a solid starting point. Good luck shipping.

Share

Thank You for Reading

Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

Business2026-05-06
Google Play Subscription Offers: Reduce Churn and Maximize Revenue for Your Rork App
A practical guide to using Google Play subscription offers — free trials, introductory pricing, upgrade offers, and win-back offers — to maximize revenue from Android apps built with Rork.
Business2026-04-28
Choosing a Revenue Model for Your Rork App — Ads, Subscriptions, or One-Time Purchase, Backed by Real Operating Numbers
Drawing on the period when my wallpaper apps hit peak ad revenue, I rebuilt the comparison of ads, subscriptions, and one-time purchases under identical conditions — with genre-by-genre guidance and implementation notes for Rork.
Business2026-03-27
Rork × Stripe Subscription Implementation— The Complete Monetization Guide
Integrate Stripe subscriptions into Rork-built apps. Covers plan design, payment flows, webhook handling, receipt validation, and churn reduction tactics.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →