Part 8 — Specialisation  ·  Track A of 4
Track A — AI Product Engineer
Build and ship polished AI-powered user-facing products that people pay for
⏱ 2–3 Weeks 🟠 Advanced 🔧 Next.js · Vercel AI SDK · Stripe · Auth.js
🎯

Track Overview

Specialisation A

Build 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

Frontend

Good 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*"]
}
🛠 Capstone: Document Q&A SaaS with Payments 2–3 weeks

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

When complete: move to Part 9 — Portfolio and Launch.

← P7-M27: MLOps All Modules Next: Track B — LLM Engineer →