Full-Stack Project — Build an AI Writing SaaS in 5 Days
Chapter 14: Full-Stack Project — Build an AI Writing SaaS in 5 Days with Cursor + Claude
Learning goals: use .cursorrules to enforce consistent AI-assisted development across files; design a Supabase schema with Row Level Security; correctly stream Claude output through a Next.js Route Handler; handle Stripe Webhook signature verification; solve Vercel function timeouts with vercel.json. All code is production-ready.
Project Goals and Tech Stack
What we're building: AI Writing Assistant SaaS
- User enters a topic, AI generates an outline and full article (~1000 words)
- Freemium model: 3 generations/day free, unlimited on Pro
- Generation history, isolated per user
| Layer | Technology | Why |
|---|---|---|
| Frontend | Next.js 14 App Router | Server Actions reduce API boilerplate; RSC is server-first by default |
| Database + Auth | Supabase | PostgreSQL + built-in Auth + Row Level Security, zero ops |
| Payments | Stripe | Industry standard for subscriptions, mature Webhook ecosystem |
| AI | Claude API (claude-haiku-4-5) | Best cost/quality ratio for writing generation; low streaming latency |
| Deployment | Vercel | Native Next.js integration, push-to-deploy |
Phase 0: Init and .cursorrules
npx create-next-app@latest ai-writer --typescript --tailwind --app
cd ai-writer
npm install @anthropic-ai/sdk @supabase/ssr stripe
# Project: AI Writer SaaS
# Stack: Next.js 14 App Router, TypeScript strict, Tailwind CSS, Supabase, Stripe
# Architecture Rules
- Server Components by default, Client Components only for interactivity/hooks
- Database operations only in Server Actions or Route Handlers, never Client Components
- Use @supabase/ssr package — NOT @supabase/auth-helpers (deprecated since 2024)
- Environment variables validated at startup via /src/lib/env.ts with Zod
# Code Style
- 2-space indent, single quotes, no semicolons
- Explicit TypeScript return types on all functions
- Error handling: return { data, error } from Server Actions, never throw
# Security
- SUPABASE_SERVICE_ROLE_KEY only used in Stripe Webhook handler
- Stripe Webhook must use req.text() not req.json()
- Every Server Action verifies user session before any DB operation
Why explicitly ban @supabase/auth-helpers: AI training data contains large amounts of pre-2024 Supabase code using the deprecated helpers package. Without an explicit ban in .cursorrules, AI will generate the deprecated pattern repeatedly across the project.
Phase 1: Database Schema
CREATE TABLE public.profiles (
id UUID REFERENCES auth.users(id) ON DELETE CASCADE PRIMARY KEY,
stripe_customer_id TEXT UNIQUE,
subscription_status TEXT NOT NULL DEFAULT 'free'
CHECK (subscription_status IN ('free', 'pro', 'cancelled')),
daily_generation_count INTEGER NOT NULL DEFAULT 0,
daily_reset_at DATE NOT NULL DEFAULT CURRENT_DATE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE public.generations (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE NOT NULL,
topic TEXT NOT NULL,
content TEXT NOT NULL,
tokens_used INTEGER,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.generations ENABLE ROW LEVEL SECURITY;
CREATE POLICY "own_profile" ON profiles FOR ALL USING (auth.uid() = id);
CREATE POLICY "own_generations" ON generations FOR ALL USING (auth.uid() = user_id);
-- Auto-create profile on signup
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$
BEGIN
INSERT INTO public.profiles (id) VALUES (NEW.id);
RETURN NEW;
END;
$$;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION handle_new_user();
Phase 3: AI Generation (Streaming Route Handler)
Critical constraint: Next.js Server Actions cannot return ReadableStream. Streaming AI output must go through a Route Handler. Client uses fetch to consume it.
// src/app/api/generate/route.ts
import Anthropic from '@anthropic-ai/sdk'
import { createServerSupabaseClient } from '@/lib/supabase/server'
const claude = new Anthropic()
export async function POST(req: Request): Promise<Response> {
const supabase = createServerSupabaseClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return Response.json({ error: 'Unauthorized' }, { status: 401 })
const { data: profile } = await supabase
.from('profiles')
.select('subscription_status, daily_generation_count, daily_reset_at')
.eq('id', user.id)
.single()
if (!profile) return Response.json({ error: 'Profile not found' }, { status: 404 })
// Reset daily count if it's a new day
const today = new Date().toISOString().split('T')[0]
if (profile.daily_reset_at !== today) {
await supabase.from('profiles')
.update({ daily_generation_count: 0, daily_reset_at: today })
.eq('id', user.id)
profile.daily_generation_count = 0
}
if (profile.subscription_status === 'free' && profile.daily_generation_count >= 3) {
return Response.json({ error: 'Daily limit reached. Upgrade to Pro.' }, { status: 429 })
}
const { topic } = await req.json()
const stream = await claude.messages.stream({
model: 'claude-haiku-4-5-20251001',
max_tokens: 2048,
messages: [{ role: 'user', content: `Write an article about "${topic}", ~1000 words, with outline and body, in Markdown.` }]
})
let fullContent = ''
const readableStream = new ReadableStream({
async start(controller) {
for await (const chunk of stream.text_stream) {
fullContent += chunk
controller.enqueue(new TextEncoder().encode(chunk))
}
controller.close()
const finalMsg = await stream.finalMessage()
await Promise.all([
supabase.from('generations').insert({
user_id: user.id, topic, content: fullContent,
tokens_used: finalMsg.usage.output_tokens
}),
supabase.from('profiles')
.update({ daily_generation_count: profile.daily_generation_count + 1 })
.eq('id', user.id)
])
}
})
return new Response(readableStream, {
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
})
}
Phase 4: Stripe Webhook — The Critical Pitfall
The single most common Stripe integration bug: using req.json() in the webhook handler. Stripe computes the signature over the raw bytes of the request body. JSON parsing and re-serialization changes the byte representation, so the signature never matches.
// src/app/api/webhooks/stripe/route.ts
import Stripe from 'stripe'
import { headers } from 'next/headers'
import { createClient } from '@supabase/supabase-js'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const supabaseAdmin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY! // service role bypasses RLS — only here
)
export async function POST(req: Request): Promise<Response> {
const body = await req.text() // MUST be text — never json
const signature = headers().get('stripe-signature')!
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!)
} catch {
return Response.json({ error: 'Invalid signature' }, { status: 400 })
}
switch (event.type) {
case 'customer.subscription.created':
case 'customer.subscription.updated': {
const sub = event.data.object as Stripe.Subscription
await supabaseAdmin.from('profiles')
.update({ subscription_status: sub.status === 'active' ? 'pro' : 'free' })
.eq('stripe_customer_id', sub.customer as string)
break
}
case 'customer.subscription.deleted': {
const sub = event.data.object as Stripe.Subscription
await supabaseAdmin.from('profiles')
.update({ subscription_status: 'cancelled' })
.eq('stripe_customer_id', sub.customer as string)
break
}
}
return Response.json({ received: true })
}
Phase 5: Deploy to Vercel
Pitfall: Vercel's default 10-second serverless function timeout kills long Claude generations. Generating 1000 words can take 15–30 seconds. Without configuration, the connection is forcibly closed mid-stream.
{
"functions": {
"src/app/api/generate/route.ts": {
"maxDuration": 60
},
"src/app/api/webhooks/stripe/route.ts": {
"maxDuration": 30
}
}
}
maxDuration above 10 requires Vercel Pro ($20/month). Free tier is hard-capped at 10 seconds. Alternative: Edge Runtime has no timeout but doesn't support all Node.js APIs — the Anthropic SDK needs extra configuration there.
Chapter Key Points
- .cursorrules prevents AI inconsistency across files — put deprecated package bans, security rules, and architecture decisions in there. Without it, AI drifts between different patterns in different files.
- Supabase RLS must be explicitly enabled after CREATE TABLE — it's off by default. The trigger that auto-creates profiles on signup must also be created separately.
- Streaming AI output belongs in Route Handlers, not Server Actions — Server Actions can't return Response or ReadableStream. Put streaming endpoints in
app/api/and call them with fetch. - Stripe Webhook always uses
req.text()— signature is computed over raw bytes. Any parsing breaks it. This is the #1 Stripe integration bug on Stack Overflow. - Plan Vercel timeouts before you deploy — free tier caps at 10 seconds, AI generation tasks need Pro with maxDuration configured, or an Edge Runtime approach.