Skip to content
ajdevhub
7 min read
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 &amp;&amp; messages[messages.length-1]?.role === "user" &amp;&amp; (
      &lt;div className="flex space-x-1 p-3"&gt;
        &lt;div className="w-2 h-2 bg-gray-300 rounded-full animate-bounce" /&gt;
        &lt;div className="w-2 h-2 bg-gray-300 rounded-full animate-bounce delay-100" /&gt;
        &lt;div className="w-2 h-2 bg-gray-300 rounded-full animate-bounce delay-200" /&gt;
      &lt;/div&gt;
    )}
  &lt;/div&gt;

  &lt;form onSubmit={handleSubmit} className="p-4 border-t flex gap-2"&gt;
    &lt;input
      value={input}
      onChange={handleInputChange}
      placeholder="Ask anything..."
      className="flex-1 p-2 border rounded"
      disabled={isLoading}
    /&gt;
    &lt;button disabled={isLoading} className="px-4 py-2 bg-blue-500 text-white rounded"&gt;
      Send
    &lt;/button&gt;
  &lt;/form&gt;
&lt;/div&gt;

) }

⚡

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

  • 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.