Learn Build a Real SaaS with Claude Code Stripe Integration: Free vs. Pro Tiers

Stripe Integration: Free vs. Pro Tiers

Intermediate 🕐 30 min Lesson 12 of 25
What you'll learn
  • Create a free Stripe account and retrieve test-mode publishable and secret keys
  • Create a test Product and recurring Price in the Stripe dashboard for a Pro tier
  • Build a Checkout Session route that creates and reuses a Stripe customer per business
  • Enforce a free-tier usage limit at the point reviews are ingested, not just in the UI
  • Explain why the checkout success redirect is a UX nicety and not a trusted source of subscription state
  • Render billing UI as plain HTML forms posting to Route Handlers, with no client JS required

Section 4: Billing and Email

With the core review/respond loop working (lessons 8-11), this section adds the two things that make this an actual SaaS rather than a free tool: billing and notifications. Start with billing, since the email alerts in lesson 14 are part of what Pro pays for.

Get a Stripe Account Before Writing Any Code

You need your own Stripe account to follow this lesson -- it is free to create at stripe.com, and test mode costs nothing and never charges a real card. After signing up:

  • Go to the Stripe Dashboard and confirm the toggle in the top corner says Test mode -- everything in this lesson uses test mode keys and test card numbers, never live ones.
  • Find your test API keys under Developers → API keys (or dashboard.stripe.com/test/apikeys): a publishable key (pk_test_...) and a secret key (sk_test_...). Put the secret key in STRIPE_SECRET_KEY and the publishable key in NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY in your .env file.
  • Go to Products in the dashboard and create a product for your Pro plan -- name it, then add a recurring Price (e.g. monthly). Copy the resulting Price id (price_...) into STRIPE_PRO_PRICE_ID. This reference app does not create that price programmatically; you create it once, by hand, in your own test account, matching the free/pro split this lesson builds against.

Nothing in this lesson requires a paid Stripe plan or a real bank account -- test mode is fully functional and free indefinitely.

The Stripe Client: lib/stripe.ts

import Stripe from "stripe";

const secretKey = process.env.STRIPE_SECRET_KEY;
if (!secretKey) {
  throw new Error("STRIPE_SECRET_KEY is not set");
}

export const stripe = new Stripe(secretKey);
export const STRIPE_PRO_PRICE_ID = process.env.STRIPE_PRO_PRICE_ID;
export const FREE_TIER_REVIEW_LIMIT = 25;

One shared client, constructed once, imported everywhere billing code needs it. The constructor intentionally omits an explicit apiVersion pin -- the installed stripe package version determines the API version used, which avoids the pin drifting out of sync with the SDK over time.

Enforcing the Limit: lib/limits.ts

The free tier is capped at a fixed number of total reviews per business, checked at ingestion time so it applies no matter which source (Google, Yelp, Facebook) a review came from:

export async function canIngestReview(business: Business): Promise {
  if (business.subscriptionTier === "pro" && business.subscriptionStatus === "active") {
    return true;
  }

  const [row] = await db
    .select({ value: count() })
    .from(reviews)
    .where(eq(reviews.businessId, business.id));

  return (row?.value ?? 0) < FREE_TIER_REVIEW_LIMIT;
}

This is called from app/api/reviews/ingest/route.ts (lesson 8) before any insert happens, returning HTTP 402 Payment Required when a free business hits the cap. 402 is an unusual but apt choice here -- it signals "this would succeed if you paid" distinctly from a generic 403 Forbidden.

Starting Checkout: app/api/billing/checkout/route.ts

This Route Handler creates a Stripe Checkout Session for the Pro plan and redirects straight to Stripe-hosted checkout -- no custom payment form to build or secure:

let customerId = business.stripeCustomerId;
if (!customerId) {
  const customer = await stripe.customers.create({
    email: user.email,
    metadata: { businessId: business.id },
  });
  customerId = customer.id;
  await db.update(businesses).set({ stripeCustomerId: customerId }).where(eq(businesses.id, business.id));
}

const session = await stripe.checkout.sessions.create({
  mode: "subscription",
  customer: customerId,
  line_items: [{ price: STRIPE_PRO_PRICE_ID, quantity: 1 }],
  success_url: `${appUrl}/dashboard/billing?checkout=success`,
  cancel_url: `${appUrl}/dashboard/billing?checkout=canceled`,
  client_reference_id: business.id,
  metadata: { businessId: business.id },
});

return NextResponse.redirect(session.url, { status: 303 });

Two details matter here per Stripe's current Checkout API: mode: "subscription" is required for a recurring price (as opposed to "payment" for a one-time charge), and the session is created against a reused Stripe customer -- the business's stripeCustomerId is created once and stored, so repeat checkouts and the billing portal (lesson 13) both resolve to the same customer instead of Stripe accumulating duplicate customer records per business.

Note what this route does not do: it does not set business.subscriptionTier = "pro" directly. The success redirect at checkout=success is a UX nicety only -- a user can close the tab before the redirect fires, or replay the URL without having paid. The webhook in lesson 13 is the actual source of truth for subscription state.

The Billing Page: app/dashboard/billing/page.tsx

A Server Component that reads the business's current subscriptionTier/subscriptionStatus and renders either an "Upgrade to Pro" form (posting to /api/billing/checkout) or a "Manage subscription" form (posting to /api/billing/portal, covered next lesson), plus a free-tier usage line:

{!isPro && (
  

{stats.total} / {FREE_TIER_REVIEW_LIMIT} reviews used on the free plan.

)}

Both forms are plain HTML <form action="..." method="POST"> elements pointing at Route Handlers -- no client JavaScript required to start a checkout or open the billing portal.

Checkout creates the session and redirects the customer to Stripe; lesson 13 covers the webhook that actually updates subscriptionTier once Stripe confirms the subscription, plus the billing portal for managing or canceling.

Key takeaways
  • Stripe test mode is free and uses sk_test_/pk_test_ keys -- no real card is ever charged while in test mode
  • A Price (not just a Product) must exist in the dashboard before Checkout can reference it via STRIPE_PRO_PRICE_ID
  • mode: "subscription" is required for a recurring price; mode: "payment" is for one-time charges
  • Reusing one Stripe customer per business (stored as stripeCustomerId) keeps Checkout and the Billing Portal pointed at the same record
  • Checking subscription limits at ingestion time, not just at the UI layer, ensures the cap holds regardless of which code path inserts a review
  • The Checkout success redirect must never be trusted to grant access by itself -- only the webhook (lesson 13) is the source of truth