🏢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?
| Layer | Technology | Reason |
|---|---|---|
| Framework | Next.js 14 (App Router) | Full-stack, RSC, streaming support |
| AI | Claude API (Anthropic SDK) | Best-in-class reasoning |
| Database | SQLite via Prisma | Zero-config, portable, production-capable |
| Auth | JWT (jose library) | Stateless, simple, no external deps |
| Deployment | Vercel | Native Next.js support, edge functions |
2. Project Setup
2.1 Initialize the project
npx create-next-app@latest ai-saas --typescript --tailwind --eslint --app --src-dir
cd ai-saas2.2 Install dependencies
npm install @anthropic-ai/sdk prisma @prisma/client jose bcryptjs
npm install -D @types/bcryptjs2.3 Environment variables
Create .env.local:
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.002.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:
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:
npx prisma migrate dev --name init
npx prisma generate4. Database Client Singleton
Create src/lib/db.ts:
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:
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
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
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:
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:
// 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:
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:
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:
// 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:
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:
"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
# Add a postinstall script to package.json
npm pkg set scripts.postinstall="prisma generate"
# Build locally to verify
npm run build14.2 Deploy
# Install Vercel CLI
npm install -g vercel
# Deploy
vercel --prod14.3 Set environment variables in Vercel
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_DAILYProduction 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
| File | Purpose |
|---|---|
prisma/schema.prisma | Database models |
src/lib/auth.ts | JWT auth helpers |
src/lib/claude.ts | Claude API wrapper |
src/lib/rate-limit.ts | Request throttling |
src/lib/cost-tracker.ts | Usage billing |
src/app/api/chat/route.ts | Core chat endpoint |
src/app/api/auth/*/route.ts | Auth endpoints |
src/app/chat/page.tsx | Chat UI |