HomeProduction & DeploymentProject: Full-Stack AI SaaS App
advanced25 min read· Module 10, Lesson 7

🏢Project: Full-Stack AI SaaS App

Build a complete SaaS application with auth, database, Claude API, and deployment

Project: Full-Stack AI SaaS App — Capstone

This is the capstone project of the entire course. You will build a production-grade AI-powered SaaS application from scratch using Next.js 14, the Claude API, Prisma + SQLite, JWT authentication, streaming responses, per-user rate limiting, and cost tracking. By the end you will have a deployable product.


1. Architecture Overview

The system follows a clean layered architecture:

┌─────────────────────────────────────────────────┐ │ Frontend (React) │ │ Next.js App Router — RSC + Client Components │ ├─────────────────────────────────────────────────┤ │ API Layer (Route Handlers) │ │ /api/auth/register POST │ │ /api/auth/login POST │ │ /api/chat POST (streaming) │ │ /api/conversations GET / POST / DELETE │ │ /api/usage GET │ ├─────────────────────────────────────────────────┤ │ Middleware & Guards │ │ JWT verification · Rate limiter · Cost guard │ ├─────────────────────────────────────────────────┤ │ Service Layer │ │ Claude API client · Auth service · Usage svc │ ├─────────────────────────────────────────────────┤ │ Database (Prisma + SQLite) │ │ Users · Conversations · Messages · UsageLogs │ └─────────────────────────────────────────────────┘

Why these choices?

LayerTechnologyReason
FrameworkNext.js 14 (App Router)Full-stack, RSC, streaming support
AIClaude API (Anthropic SDK)Best-in-class reasoning
DatabaseSQLite via PrismaZero-config, portable, production-capable
AuthJWT (jose library)Stateless, simple, no external deps
DeploymentVercelNative Next.js support, edge functions

2. Project Setup

2.1 Initialize the project

Terminal
npx create-next-app@latest ai-saas --typescript --tailwind --eslint --app --src-dir cd ai-saas

2.2 Install dependencies

Terminal
npm install @anthropic-ai/sdk prisma @prisma/client jose bcryptjs npm install -D @types/bcryptjs

2.3 Environment variables

Create .env.local:

ENV
ANTHROPIC_API_KEY=sk-ant-... JWT_SECRET=your-secret-key-min-32-chars-long-here DATABASE_URL="file:./dev.db" RATE_LIMIT_MAX=20 RATE_LIMIT_WINDOW_MS=60000 MAX_COST_PER_USER_DAILY=5.00

2.4 Project structure

src/ ├── app/ │ ├── api/ │ │ ├── auth/ │ │ │ ├── register/route.ts │ │ │ └── login/route.ts │ │ ├── chat/route.ts │ │ ├── conversations/route.ts │ │ └── usage/route.ts │ ├── chat/page.tsx │ ├── login/page.tsx │ ├── register/page.tsx │ └── layout.tsx ├── lib/ │ ├── auth.ts │ ├── claude.ts │ ├── db.ts │ ├── rate-limit.ts │ └── cost-tracker.ts └── prisma/ └── schema.prisma

3. Database Schema (Prisma)

Create prisma/schema.prisma:

PRISMA
generator client { provider = "prisma-client-js" } datasource db { provider = "sqlite" url = env("DATABASE_URL") } model User { id String @id @default(cuid()) email String @unique passwordHash String createdAt DateTime @default(now()) conversations Conversation[] usageLogs UsageLog[] } model Conversation { id String @id @default(cuid()) title String @default("New conversation") userId String user User @relation(fields: [userId], references: [id]) messages Message[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Message { id String @id @default(cuid()) role String // "user" | "assistant" content String conversationId String conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade) inputTokens Int @default(0) outputTokens Int @default(0) createdAt DateTime @default(now()) } model UsageLog { id String @id @default(cuid()) userId String user User @relation(fields: [userId], references: [id]) inputTokens Int outputTokens Int costUsd Float model String createdAt DateTime @default(now()) }

Run migrations:

Terminal
npx prisma migrate dev --name init npx prisma generate

4. Database Client Singleton

Create src/lib/db.ts:

TypeScript
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined; }, export const db = globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV !== "production") { globalForPrisma.prisma = db; }

5. Authentication (JWT)

Create src/lib/auth.ts:

TypeScript
const secret = new TextEncoder().encode(process.env.JWT_SECRET!); // ── Token helpers ────────────────────────────────────── export async function createToken(userId: string): Promise<string> { return new SignJWT({ sub: userId }) .setProtectedHeader({ alg: "HS256" }) .setIssuedAt() .setExpirationTime("7d") .sign(secret); } export async function verifyToken( token: string ): Promise<{ sub: string } | null> { try { const { payload } = await jwtVerify(token, secret); return payload as { sub: string }; } catch { return null; } } // ── User operations ──────────────────────────────────── export async function registerUser(email: string, password: string) { const existing = await db.user.findUnique({ where: { email } }); if (existing) throw new Error("Email already registered"); const passwordHash = await hash(password, 12); const user = await db.user.create({ data: { email, passwordHash }, }); const token = await createToken(user.id); return { user: { id: user.id, email: user.email }, token }; } export async function loginUser(email: string, password: string) { const user = await db.user.findUnique({ where: { email } }); if (!user) throw new Error("Invalid credentials"); const valid = await compare(password, user.passwordHash); if (!valid) throw new Error("Invalid credentials"); const token = await createToken(user.id); return { user: { id: user.id, email: user.email }, token }; } // ── Middleware helper ────────────────────────────────── export async function getAuthUser(req: NextRequest) { const authHeader = req.headers.get("authorization"); if (!authHeader?.startsWith("Bearer ")) return null; const token = authHeader.slice(7); const payload = await verifyToken(token); if (!payload?.sub) return null; const user = await db.user.findUnique({ where: { id: payload.sub }, }); return user; }

6. Auth API Routes

6.1 Register — src/app/api/auth/register/route.ts

TypeScript
export async function POST(req: NextRequest) { try { const { email, password } = await req.json(); if (!email || !password) { return NextResponse.json( { error: "Email and password are required" }, { status: 400 } ); } if (password.length < 8) { return NextResponse.json( { error: "Password must be at least 8 characters" }, { status: 400 } ); } const result = await registerUser(email, password); return NextResponse.json(result, { status: 201 }); } catch (err: unknown) { const message = err instanceof Error ? err.message : "Registration failed"; return NextResponse.json({ error: message }, { status: 400 }); } }

6.2 Login — src/app/api/auth/login/route.ts

TypeScript
export async function POST(req: NextRequest) { try { const { email, password } = await req.json(); if (!email || !password) { return NextResponse.json( { error: "Email and password are required" }, { status: 400 } ); } const result = await loginUser(email, password); return NextResponse.json(result); } catch (err: unknown) { const message = err instanceof Error ? err.message : "Login failed"; return NextResponse.json({ error: message }, { status: 401 }); } }

7. Rate Limiting

Create src/lib/rate-limit.ts:

TypeScript
const WINDOW_MS = parseInt(process.env.RATE_LIMIT_WINDOW_MS || "60000", 10); const MAX_REQUESTS = parseInt(process.env.RATE_LIMIT_MAX || "20", 10); interface RateLimitEntry { count: number; resetAt: number; } // In-memory store — in production use Redis const store = new Map<string, RateLimitEntry>(); export function checkRateLimit(userId: string): { allowed: boolean; remaining: number; resetAt: number; } { const now = Date.now(); const entry = store.get(userId); // First request or window expired if (!entry || now > entry.resetAt) { const resetAt = now + WINDOW_MS; store.set(userId, { count: 1, resetAt }); return { allowed: true, remaining: MAX_REQUESTS - 1, resetAt }; } // Within window if (entry.count >= MAX_REQUESTS) { return { allowed: false, remaining: 0, resetAt: entry.resetAt }; } entry.count++; return { allowed: true, remaining: MAX_REQUESTS - entry.count, resetAt: entry.resetAt, }; } // Clean up expired entries every 5 minutes setInterval(() => { const now = Date.now(); for (const [key, entry] of store.entries()) { if (now > entry.resetAt) store.delete(key); } }, 5 * 60 * 1000);

8. Cost Tracking

Create src/lib/cost-tracker.ts:

TypeScript
// Claude 3.5 Sonnet pricing (per 1M tokens) const PRICING: Record<string, { input: number; output: number }> = { "claude-sonnet-4-20250514": { input: 3.0, output: 15.0 }, "claude-haiku-4-20250414": { input: 0.25, output: 1.25 }, }, export function calculateCost( model: string, inputTokens: number, outputTokens: number ): number { const pricing = PRICING[model] || PRICING["claude-sonnet-4-20250514"]; const inputCost = (inputTokens / 1_000_000) * pricing.input; const outputCost = (outputTokens / 1_000_000) * pricing.output; return Math.round((inputCost + outputCost) * 1_000_000) / 1_000_000; } export async function logUsage( userId: string, model: string, inputTokens: number, outputTokens: number ) { const costUsd = calculateCost(model, inputTokens, outputTokens); await db.usageLog.create({ data: { userId, model, inputTokens, outputTokens, costUsd }, }); return costUsd; } export async function getDailyUsage(userId: string): Promise<number> { const startOfDay = new Date(); startOfDay.setHours(0, 0, 0, 0); const result = await db.usageLog.aggregate({ where: { userId, createdAt: { gte: startOfDay }, }, _sum: { costUsd: true }, }); return result._sum.costUsd || 0; } export async function checkCostLimit(userId: string): Promise<{ allowed: boolean; dailySpent: number; dailyLimit: number; }> { const dailyLimit = parseFloat( process.env.MAX_COST_PER_USER_DAILY || "5.00" ); const dailySpent = await getDailyUsage(userId); return { allowed: dailySpent < dailyLimit, dailySpent, dailyLimit, }; }

9. Claude API Client with Streaming

Create src/lib/claude.ts:

TypeScript
const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY!, }); const DEFAULT_MODEL = "claude-sonnet-4-20250514"; const MAX_TOKENS = 2048; interface ChatMessage { role: "user" | "assistant"; content: string; } // ── Non-streaming call ───────────────────────────────── export async function chatCompletion( messages: ChatMessage[], systemPrompt?: string ) { const response = await anthropic.messages.create({ model: DEFAULT_MODEL, max_tokens: MAX_TOKENS, system: systemPrompt || "You are a helpful AI assistant.", messages, }); const text = response.content[0].type === "text" ? response.content[0].text : ""; return { text, inputTokens: response.usage.input_tokens, outputTokens: response.usage.output_tokens, model: DEFAULT_MODEL, }; } // ── Streaming call ───────────────────────────────────── export async function chatStream( messages: ChatMessage[], systemPrompt?: string ) { const stream = anthropic.messages.stream({ model: DEFAULT_MODEL, max_tokens: MAX_TOKENS, system: systemPrompt || "You are a helpful AI assistant.", messages, }); return stream; }

10. Chat API Route (Streaming)

Create src/app/api/chat/route.ts — this is the core route:

TypeScript
export async function POST(req: NextRequest) { // ── 1. Authenticate ──────────────────────────────── const user = await getAuthUser(req); if (!user) { return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" }, }); } // ── 2. Rate limit ───────────────────────────────── const rateCheck = checkRateLimit(user.id); if (!rateCheck.allowed) { return new Response( JSON.stringify({ error: "Rate limit exceeded", resetAt: rateCheck.resetAt, }), { status: 429, headers: { "Content-Type": "application/json", "X-RateLimit-Remaining": "0", "X-RateLimit-Reset": String(rateCheck.resetAt), }, } ); } // ── 3. Cost limit ───────────────────────────────── const costCheck = await checkCostLimit(user.id); if (!costCheck.allowed) { return new Response( JSON.stringify({ error: "Daily cost limit reached", dailySpent: costCheck.dailySpent, dailyLimit: costCheck.dailyLimit, }), { status: 429, headers: { "Content-Type": "application/json" } } ); } // ── 4. Parse body ───────────────────────────────── const { message, conversationId } = await req.json(); if (!message || typeof message !== "string") { return new Response( JSON.stringify({ error: "Message is required" }), { status: 400, headers: { "Content-Type": "application/json" } } ); } // ── 5. Load or create conversation ──────────────── let conversation; if (conversationId) { conversation = await db.conversation.findFirst({ where: { id: conversationId, userId: user.id }, include: { messages: { orderBy: { createdAt: "asc" } } }, }); if (!conversation) { return new Response( JSON.stringify({ error: "Conversation not found" }), { status: 404, headers: { "Content-Type": "application/json" } } ); } } else { conversation = await db.conversation.create({ data: { userId: user.id, title: message.slice(0, 60), }, include: { messages: true }, }); } // ── 6. Build message history ────────────────────── const history = conversation.messages.map((m) => ({ role: m.role as "user" | "assistant", content: m.content, })); history.push({ role: "user", content: message }); // ── 7. Save user message ────────────────────────── await db.message.create({ data: { role: "user", content: message, conversationId: conversation.id, }, }); // ── 8. Stream from Claude ───────────────────────── const stream = await chatStream(history); const encoder = new TextEncoder(); let fullResponse = ""; let inputTokens = 0; let outputTokens = 0; const readable = new ReadableStream({ async start(controller) { try { const response = await stream.finalMessage(); // We use the event-based approach for real streaming stream.on("text", (text) => { fullResponse += text; controller.enqueue( encoder.encode(`data: ${JSON.stringify({ text })}\n\n`) ); }); stream.on("finalMessage", async (msg) => { inputTokens = msg.usage.input_tokens; outputTokens = msg.usage.output_tokens; // Save assistant message await db.message.create({ data: { role: "assistant", content: fullResponse, conversationId: conversation.id, inputTokens, outputTokens, }, }); // Log usage await logUsage( user.id, "claude-sonnet-4-20250514", inputTokens, outputTokens ); controller.enqueue( encoder.encode( `data: ${JSON.stringify({ done: true, conversationId: conversation.id, usage: { inputTokens, outputTokens }, })}\n\n` ) ); controller.close(); }); // Actually consume the stream await response; } catch (error) { const errMsg = error instanceof Error ? error.message : "Stream failed"; controller.enqueue( encoder.encode( `data: ${JSON.stringify({ error: errMsg })}\n\n` ) ); controller.close(); } }, }); return new Response(readable, { headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", "X-RateLimit-Remaining": String(rateCheck.remaining), }, }); }

11. Conversations API

Create src/app/api/conversations/route.ts:

TypeScript
// List all conversations for the authenticated user export async function GET(req: NextRequest) { const user = await getAuthUser(req); if (!user) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } const conversations = await db.conversation.findMany({ where: { userId: user.id }, orderBy: { updatedAt: "desc" }, include: { messages: { orderBy: { createdAt: "desc" }, take: 1, }, _count: { select: { messages: true } }, }, }); return NextResponse.json({ conversations }); } // Delete a conversation export async function DELETE(req: NextRequest) { const user = await getAuthUser(req); if (!user) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } const { conversationId } = await req.json(); if (!conversationId) { return NextResponse.json( { error: "conversationId is required" }, { status: 400 } ); } const conversation = await db.conversation.findFirst({ where: { id: conversationId, userId: user.id }, }); if (!conversation) { return NextResponse.json( { error: "Conversation not found" }, { status: 404 } ); } await db.conversation.delete({ where: { id: conversationId } }); return NextResponse.json({ deleted: true }); }

12. Usage API

Create src/app/api/usage/route.ts:

TypeScript
export async function GET(req: NextRequest) { const user = await getAuthUser(req); if (!user) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } const dailyCost = await getDailyUsage(user.id); const dailyLimit = parseFloat( process.env.MAX_COST_PER_USER_DAILY || "5.00" ); // Last 30 days of usage const thirtyDaysAgo = new Date(); thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); const recentLogs = await db.usageLog.findMany({ where: { userId: user.id, createdAt: { gte: thirtyDaysAgo }, }, orderBy: { createdAt: "desc" }, take: 100, }); const totalSpent = recentLogs.reduce((sum, log) => sum + log.costUsd, 0); const totalInputTokens = recentLogs.reduce( (sum, log) => sum + log.inputTokens, 0 ); const totalOutputTokens = recentLogs.reduce( (sum, log) => sum + log.outputTokens, 0 ); return NextResponse.json({ daily: { spent: dailyCost, limit: dailyLimit }, monthly: { totalSpent: Math.round(totalSpent * 1_000_000) / 1_000_000, totalInputTokens, totalOutputTokens, requestCount: recentLogs.length, }, recentLogs: recentLogs.slice(0, 20), }); }

13. Frontend — Chat Page

Create src/app/chat/page.tsx:

TSX
"use client"; interface Message { role: "user" | "assistant"; content: string; } export default function ChatPage() { const [messages, setMessages] = useState<Message[]>([]); const [input, setInput] = useState(""); const [loading, setLoading] = useState(false); const [conversationId, setConversationId] = useState<string | null>(null); const bottomRef = useRef<HTMLDivElement>(null); useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages]); const sendMessage = async () => { if (!input.trim() || loading) return; const userMsg: Message = { role: "user", content: input }; setMessages((prev) => [...prev, userMsg]); setInput(""); setLoading(true); try { const token = localStorage.getItem("token"); const res = await fetch("/api/chat", { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, body: JSON.stringify({ message: input, conversationId, }), }); if (!res.ok) { const err = await res.json(); throw new Error(err.error || "Request failed"); } // Read the SSE stream const reader = res.body!.getReader(); const decoder = new TextDecoder(); let assistantText = ""; setMessages((prev) => [ ...prev, { role: "assistant", content: "" }, ]); while (true) { const { done, value } = await reader.read(); if (done) break; const chunk = decoder.decode(value); const lines = chunk.split("\n"); for (const line of lines) { if (!line.startsWith("data: ")) continue; const data = JSON.parse(line.slice(6)); if (data.text) { assistantText += data.text; setMessages((prev) => { const updated = [...prev]; updated[updated.length - 1] = { role: "assistant", content: assistantText, }; return updated; }); } if (data.conversationId) { setConversationId(data.conversationId); } } } } catch (err: unknown) { const errMsg = err instanceof Error ? err.message : "Something went wrong"; setMessages((prev) => [ ...prev, { role: "assistant", content: `Error: ${errMsg}` }, ]); } finally { setLoading(false); } }; return ( <div className="flex flex-col h-screen max-w-3xl mx-auto"> <header className="p-4 border-b"> <h1 className="text-xl font-bold">AI Chat</h1> </header> <div className="flex-1 overflow-y-auto p-4 space-y-4"> {messages.map((msg, i) => ( <div key={i} className={`flex ${ msg.role === "user" ? "justify-end" : "justify-start" }`} > <div className={`max-w-[80%] rounded-lg p-3 ${ msg.role === "user" ? "bg-blue-600 text-white" : "bg-gray-100 text-gray-900" }`} > <pre className="whitespace-pre-wrap font-sans"> {msg.content} </pre> </div> </div> ))} <div ref={bottomRef} /> </div> <div className="p-4 border-t"> <div className="flex gap-2"> <input type="text" value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={(e) => e.key === "Enter" && sendMessage()} placeholder="Type a message..." className="flex-1 border rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" disabled={loading} /> <button onClick={sendMessage} disabled={loading || !input.trim()} className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50" > {loading ? "..." : "Send"} </button> </div> </div> </div> ); }

14. Deployment to Vercel

14.1 Prepare for deployment

Terminal
# Add a postinstall script to package.json npm pkg set scripts.postinstall="prisma generate" # Build locally to verify npm run build

14.2 Deploy

Terminal
# Install Vercel CLI npm install -g vercel # Deploy vercel --prod

14.3 Set environment variables in Vercel

Terminal
vercel env add ANTHROPIC_API_KEY vercel env add JWT_SECRET vercel env add DATABASE_URL vercel env add RATE_LIMIT_MAX vercel env add RATE_LIMIT_WINDOW_MS vercel env add MAX_COST_PER_USER_DAILY

Production note: For a real SaaS, replace SQLite with PostgreSQL (e.g., Neon or Supabase) and the in-memory rate limiter with Redis (e.g., Upstash). The architecture stays the same — only the adapters change.


15. What You Built

Congratulations! You now have a working AI SaaS application with:

  • User registration and login (JWT-based)
  • Persistent conversation history (Prisma + SQLite)
  • Streaming AI responses (Claude API + SSE)
  • Per-user rate limiting (in-memory, swappable to Redis)
  • Cost tracking and daily limits (prevents billing surprises)
  • Clean API design (RESTful routes with proper error handling)
  • Production deployment (Vercel-ready)

This capstone combines every concept from the course: API integration, prompt design, streaming, error handling, security, and deployment. You have a real product foundation to build on.


Quick Reference

FilePurpose
prisma/schema.prismaDatabase models
src/lib/auth.tsJWT auth helpers
src/lib/claude.tsClaude API wrapper
src/lib/rate-limit.tsRequest throttling
src/lib/cost-tracker.tsUsage billing
src/app/api/chat/route.tsCore chat endpoint
src/app/api/auth/*/route.tsAuth endpoints
src/app/chat/page.tsxChat UI