🏠 Index | ⬅️ Prev (Ch.09) | ➡️ Next (Ch.11) | 🌐 中文版
Ch.10 Monetization in Practice: Shipping a Commercial SaaS MVP in 2 Hours
🎯 The Real Problem: Spending two weeks wrestling with authentication, database schemas, Stripe Webhook signature verification, and cloud hosting before shipping anything.
💡 Tangible Output & Takeaway: Fully runnable production repoexamples/ch10-saas-mvp(Next.js 15 + Supabase + Stripe subscriptions) and local Stripe CLI test loops.
⚡ Viral Screenshot Quote: "The ultimate milestone for indie developers isn't architectural perfection—it's receiving the first customer payment. Ship monetization in 2 hours."
As an independent developer (Indie Hacker) or micro-startup, your core milestone is not building a "perfect architecture"—it is "receiving your first payment." Many developers waste time repeatedly configuring boilerplate templates, delaying their actual launch.
In this chapter, in a fast-paced hacker style, we will teach you how to direct Codex to ship a SaaS MVP with a complete payment and subscription access control loop in under 2 hours using Next.js 15 (App Router) + Supabase (PostgreSQL) + Stripe.
📦 Companion Source Code: examples/ch10-saas-mvp — a fully runnable subscription-based AI translator (TransFlow) with its own CAP
AGENTS.md. Verified withnpm install && npm run build.
10.1 Initialization and Database Schema Design
Our goal is to build a subscription-based AI translation service. First, initialize the project using Next.js 16's create-next-app, which generates AGENTS.md by default, allowing you to lock down boundary rules from the start. Next, instruct Codex to generate the core database models.
1. Designing the Prisma Schema (Database Entity Modeling)
Dispatch the following goal-driven specs to Codex:
# 🎯 Goal
Write Prisma database models supporting User, Subscription, and TranslationRecord entities.
# 🛑 Constraints
- Use PostgreSQL (connected via Supabase) as the database provider.
- Subscription status must be an Enum type containing ACTIVE, CANCELED, and EXPIRED.
# 🧪 Validation Specs
- Running `npx prisma validate` must return no syntax or definition errors.Codex will automatically output a standard schema.prisma file containing foreign key relationships, cascade deletes, and database indexes:
// File: prisma/schema.prisma
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
enum SubscriptionStatus {
ACTIVE
CANCELED
EXPIRED
}
model User {
id String @id @default(uuid())
email String @unique
createdAt DateTime @default(now())
subscription Subscription?
records TranslationRecord[]
}
model Subscription {
id String @id @default(uuid())
userId String @unique
stripeSubId String @unique
status SubscriptionStatus
priceId String
currentPeriodEnd DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model TranslationRecord {
id String @id @default(uuid())
userId String
sourceText String
translatedText String
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}10.2 Integrating Stripe Subscriptions and Webhook Handlers
The core of a payment system is callback security. When a user successfully checks out, Stripe's servers send a webhook request to your Next.js application. We need Codex to write the verification and subscription state flow logic.
Practice: Dispatching Webhook Route Specifications to Codex
# 🎯 Goal
Implement a Next.js 16 App Router style Stripe Webhook route handler `/api/webhooks/stripe`.
# 🛑 Constraints
- Verify the signature of incoming webhook requests using `stripe.webhooks.constructEvent` to prevent forgery attacks.
- Update user subscription status in the database on receiving `checkout.session.completed` or `invoice.payment_succeeded` events.
- Must use the latest stable Stripe API version (2026-04-22.dahlia at the time of writing).
- Do not parse the request body as JSON. Stripe verification requires the raw request body string.
# 🧪 Validation Specs
- Write automated mock requests verifying that invalid signatures return HTTP 400 Bad Request, while valid sessions return HTTP 200 OK.
- The actual subscription billing period must be retrieved from the Stripe response, rather than being hardcoded.Codex will autonomously search the sandbox for solutions (such as reading the raw body using request.text() in Next.js 16) and generate the compliant handler code:
// File: src/app/api/webhooks/stripe/route.ts
import { NextResponse } from 'next/server';
import Stripe from 'stripe';
import { prisma } from '@/lib/prisma';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
// Use the latest stable API version at the time of writing; check the Stripe Changelog before going live
apiVersion: '2026-04-22.dahlia',
});
export async function POST(req: Request) {
const body = await req.text();
const signature = req.headers.get('stripe-signature')!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err: any) {
return NextResponse.json({ error: `Webhook Error: ${err.message}` }, { status: 400 });
}
// Handle state transition
if (event.type === 'checkout.session.completed') {
const session = event.data.object as Stripe.Checkout.Session;
const stripeSubId = session.subscription as string;
const customerEmail = session.customer_details?.email!;
// Production practice: Retrieve the actual subscription object from Stripe to get the accurate current_period_end
const subscription = await stripe.subscriptions.retrieve(stripeSubId);
await prisma.subscription.upsert({
where: { stripeSubId },
update: {
status: 'ACTIVE',
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
},
create: {
stripeSubId,
status: 'ACTIVE',
priceId: subscription.items.data[0]?.price.id || 'default',
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
user: { connect: { email: customerEmail } }
}
});
}
return NextResponse.json({ received: true });
}💡 Avoid the Pitfall: Many tutorials in the past hardcoded
currentPeriodEnd = Date.now() + 30 * 24 * 60 * 60 * 1000. This will calculate incorrectly in scenarios like annual subscriptions, trial periods, or coupon discounts. Always retrieve the actual timestamp fromstripe.subscriptions.retrieve.
10.3 Local Debugging: Using Stripe CLI for Payment Integration
Debugging Stripe locally requires the Stripe CLI to forward webhooks. Using the network penetration techniques covered in Ch.03, we can configure our local environment:
Run the Stripe webhook forwarding command locally:
bashstripe listen --forward-to localhost:3000/api/webhooks/stripeAdd the
whsec\_xxxwebhook signing secret printed by the console to your local\.envfile and instruct Codex to run the verification tests.
By following this loop (Spec Definition -> AI Coding -> Sandbox Validation -> Real-World Stripe Integration Test), independent developers can compress what normally takes two days of integration struggle down to under 30 minutes.
The value of a commercial MVP lies in speed. Enforce strict boundaries on the AI to swap for maximum speed-to-market.