Chapter 14

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

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

  1. .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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
Rate this chapter
4.6  / 5  (18 ratings)

💬 Comments