Track Overview
Specialisation ABuild polished, user-facing AI products that people actually pay for. This track bridges AI engineering with product and frontend skills — the combination that commands the highest salaries in the current market. You will build a complete SaaS AI product from landing page to payment to cancellation.
Skills You Will Build
- Streaming chat UI with Vercel AI SDK useChat() hook — token-by-token rendering
- Next.js App Router server actions and route handlers for AI backends
- Auth.js for multi-provider authentication (Google, GitHub, email)
- Stripe subscriptions: checkout session, webhooks, tier enforcement
- Per-user usage limits enforced via Next.js middleware
- Usage tracking in Redis with daily expiry
- Deploy to Vercel with GitHub Actions CI/CD
💡 Pick ONE track. Each track is a 2-3 week deep dive. Choose the one that matches your target role. All tracks build on the same Part 1-7 foundation.
AI UX Patterns That Convert
FrontendGood AI UX solves three problems: streaming makes responses feel fast, skeleton states manage user expectations, and error recovery keeps users when the LLM fails.
// app/components/chat.tsx — streaming chat with skeleton loader
"use client"
import { useChat } from "ai/react"
export function ChatInterface() {
const { messages, input, handleInputChange, handleSubmit, isLoading } = useChat({
api: "/api/chat",
onError: (error) => console.error("Chat error:", error),
})
return (
<div className="flex flex-col h-screen">
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.map(m => (
<div key={m.id} className={m.role === "user" ? "text-right" : "text-left"}>
<div className={`inline-block p-3 rounded-lg max-w-[80%] ${
m.role === "user" ? "bg-blue-500 text-white" : "bg-gray-100"
}`}>
{m.content}
{/* Blinking cursor while streaming */}
{m.role === "assistant" && isLoading &&
<span className="animate-pulse ml-0.5">|</span>}
</div>
</div>
))}
{/* Skeleton loader — shows between user message and first token */}
{isLoading && messages[messages.length-1]?.role === "user" && (
<div className="flex space-x-1 p-3">
<div className="w-2 h-2 bg-gray-300 rounded-full animate-bounce" />
<div className="w-2 h-2 bg-gray-300 rounded-full animate-bounce delay-100" />
<div className="w-2 h-2 bg-gray-300 rounded-full animate-bounce delay-200" />
</div>
)}
</div>
<form onSubmit={handleSubmit} className="p-4 border-t flex gap-2">
<input
value={input}
onChange={handleInputChange}
placeholder="Ask anything..."
className="flex-1 p-2 border rounded"
disabled={isLoading}
/>
<button disabled={isLoading} className="px-4 py-2 bg-blue-500 text-white rounded">
Send
</button>
</form>
</div>
)
}Vercel AI SDK — Server Route
Backend// app/api/chat/route.ts — streaming with tool calls
import { streamText } from "ai"
import { anthropic } from "@ai-sdk/anthropic"
import { z } from "zod"
import { getServerSession } from "next-auth"
import { checkUsageLimit, incrementUsage } from "@/lib/usage"
export async function POST(req: Request) {
const session = await getServerSession()
if (!session) return new Response("Unauthorized", { status: 401 })
// Check usage limit for user's tier
const withinLimit = await checkUsageLimit(session.user.id, session.user.tier)
if (!withinLimit) {
return Response.json({ error: "Daily limit reached. Upgrade to Pro." }, { status: 429 })
}
const { messages } = await req.json()
const result = await streamText({
model: anthropic("claude-3-5-sonnet-20241022"),
system: "You are a helpful assistant. Answer questions clearly and concisely.",
messages,
tools: {
searchDocs: {
description: "Search the knowledge base for relevant documentation",
parameters: z.object({ query: z.string() }),
execute: async ({ query }) => {
const results = await ragSearch(query)
return results
}
}
},
onFinish: async ({ usage }) => {
// Track token usage after response complete
await incrementUsage(session.user.id, usage.totalTokens)
}
})
return result.toDataStreamResponse()
}
// lib/usage.ts — Redis-backed usage tracking
import { redis } from "./redis"
const TIER_LIMITS = {
free: 10,
pro: 500,
team: 5000,
}
export async function checkUsageLimit(userId: string, tier: string): Promise<boolean> {
const key = `usage:${userId}:${new Date().toISOString().split("T")[0]}`
const count = parseInt(await redis.get(key) || "0")
return count < (TIER_LIMITS[tier as keyof typeof TIER_LIMITS] ?? 10)
}
export async function incrementUsage(userId: string, tokens: number) {
const key = `usage:${userId}:${new Date().toISOString().split("T")[0]}`
await redis.incr(key)
await redis.expire(key, 86400) // 24h TTL
}Stripe — Subscriptions and Webhooks
Monetisation// app/api/stripe/checkout/route.ts
import Stripe from "stripe"
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function POST(req: Request) {
const session = await getServerSession()
if (!session) return new Response("Unauthorized", { status: 401 })
const { priceId } = await req.json()
const origin = req.headers.get("origin")
const checkoutSession = await stripe.checkout.sessions.create({
customer_email: session.user.email!,
line_items: [{ price: priceId, quantity: 1 }],
mode: "subscription",
success_url: `${origin}/dashboard?upgraded=true`,
cancel_url: `${origin}/pricing`,
metadata: { userId: session.user.id }
})
return Response.json({ url: checkoutSession.url })
}
// app/api/stripe/webhook/route.ts — update DB on payment events
export async function POST(req: Request) {
const body = await req.text()
const sig = req.headers.get("stripe-signature")!
const event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!)
switch (event.type) {
case "checkout.session.completed":
await db.user.update({
where: { id: event.data.object.metadata!.userId },
data: { tier: "pro", stripeSubscriptionId: event.data.object.subscription as string }
})
break
case "customer.subscription.deleted":
await db.user.update({
where: { stripeSubscriptionId: event.data.object.id },
data: { tier: "free", stripeSubscriptionId: null }
})
break
}
return Response.json({ received: true })
}Auth.js Setup and Tier Enforcement
Security// auth.ts — Auth.js v5 configuration
import NextAuth from "next-auth"
import Google from "next-auth/providers/google"
import GitHub from "next-auth/providers/github"
import { PrismaAdapter } from "@auth/prisma-adapter"
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(db),
providers: [
Google({ clientId: process.env.GOOGLE_CLIENT_ID!, clientSecret: process.env.GOOGLE_CLIENT_SECRET! }),
GitHub({ clientId: process.env.GITHUB_CLIENT_ID!, clientSecret: process.env.GITHUB_CLIENT_SECRET! }),
],
callbacks: {
session: async ({ session, user }) => {
// Attach tier from DB to session — available in all components
session.user.id = user.id
session.user.tier = user.tier || "free"
return session
}
}
})
// middleware.ts — protect routes and enforce limits
import { auth } from "@/auth"
export default auth((req) => {
const isAuth = !!req.auth
// Protect dashboard routes
if (req.nextUrl.pathname.startsWith("/dashboard") && !isAuth) {
return Response.redirect(new URL("/login", req.url))
}
})
export const config = {
matcher: ["/dashboard/:path*", "/api/chat/:path*"]
}Build a complete SaaS AI product: a document Q&A app that users pay for.
Requirements
- Landing page with feature highlights and pricing (Free / Pro)
- Google + GitHub auth via Auth.js — session includes tier
- Free tier: 10 queries/day, Pro tier: 500 queries/day
- Stripe Pro subscription at $19/month — working checkout and webhook
- Streaming chat UI with skeleton loader and error recovery
- RAG backend from M18 — user can upload and query their own documents
- Usage dashboard showing: queries today, tier, days until renewal
- Deployed to Vercel (frontend) + Railway or Render (FastAPI backend)
This is your primary portfolio piece for AI product engineer roles. A working, deployed, paying-customer-capable product beats any tutorial project.
MASTERY CHECKLIST
- Can build streaming chat UI with useChat() — shows skeleton loader before first token
- Can implement streamText() server route with tool calls and onFinish callback
- Can set up Auth.js v5 with Google and GitHub providers and attach custom user fields to session
- Can create Stripe checkout session and redirect user to Stripe-hosted payment page
- Can handle Stripe webhooks: checkout.session.completed updates user tier in database
- Can enforce tier limits: count daily requests in Redis with 24h TTL, return 429 when exceeded
- Capstone project deployed to Vercel — publicly accessible URL with working auth and payments
When complete: move to Part 9 — Portfolio and Launch.