# Vercel Academy — Full Content Export > Complete course content for all published modules and lessons. > Individual pages: append `.md` to any URL (e.g. `/academy/slack-agents.md`) > Course index: [/academy/llms.txt](/academy/llms.txt) --- title: "Launch a Subscription Store with Vercel and Stripe" description: "Build a production-ready subscription storefront with Next.js 16, React 19, Supabase Auth, and Stripe. Learn authentication, payments, and access control." canonical_url: "https://vercel.com/academy/subscription-store" md_url: "https://vercel.com/academy/subscription-store.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-09-22T04:50:15.726Z" content_type: "course" lessons: 17 estimated_time: lesson_urls: - "https://vercel.com/academy/subscription-store/deploy-your-starter.md" - "https://vercel.com/academy/subscription-store/supabase-project-setup.md" - "https://vercel.com/academy/subscription-store/supabase-client-utilities.md" - "https://vercel.com/academy/subscription-store/sign-up-and-sign-in-pages.md" - "https://vercel.com/academy/subscription-store/proxy-and-protected-routes.md" - "https://vercel.com/academy/subscription-store/stripe-sdk-setup.md" - "https://vercel.com/academy/subscription-store/pricing-page-with-plans.md" - "https://vercel.com/academy/subscription-store/stripe-checkout-flow.md" - "https://vercel.com/academy/subscription-store/subscription-management-page.md" - "https://vercel.com/academy/subscription-store/subscription-actions.md" - "https://vercel.com/academy/subscription-store/understanding-access-control.md" - "https://vercel.com/academy/subscription-store/server-side-subscription-checks.md" - "https://vercel.com/academy/subscription-store/client-side-subscription-checks.md" - "https://vercel.com/academy/subscription-store/protected-api-routes.md" - "https://vercel.com/academy/subscription-store/error-handling-and-loading-states.md" - "https://vercel.com/academy/subscription-store/header-and-navigation.md" - "https://vercel.com/academy/subscription-store/deploy-to-production.md" --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Launch a Subscription Store with Vercel and Stripe You have a great idea that you want to sell, but you need a place to sell it. Building a SaaS app can send you down a rabbit hole. Authentication. Payments. Protecting premium features. Managing subscriptions. Each one pulls you deeper, and before you know it, you've spent three weeks on deciding on how to build instead of building. Let's build a production-ready subscription storefront with Next.js 16, Supabase Auth, and Stripe, a solid stack for building SaaS apps. You'll understand exactly how the pieces fit together, not because you copied code that magically works, but because you built it yourself. By the end, you'll have a complete billing system ready for your next big idea. ### What you'll build (hands-on) We're going to build a subscription storefront for a small business called The Forager's Guild. Use that example or build your own idea that will include: **Authentication System:** - Sign up and sign in flows with Supabase Auth - Protected routes with Next.js 16 proxy - Session management across server and client components **Subscription Billing:** - Pricing page with multiple subscription tiers - Stripe Checkout integration with Server Actions - Subscription management (view, cancel via Customer Portal) - Stripe webhooks syncing data to Supabase **Feature Access Control:** - Gate content based on subscription status - Server-side and client-side subscription checks - Protected API routes with access control ## Prerequisites Before diving in, make sure you have: - JavaScript/TypeScript: Comfortable with modern JS syntax and basic TS concepts - React: Familiar with components, hooks, and state management - Git: [Version control system](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) for cloning the starter code - Node.js: [Latest LTS version](https://nodejs.org/en/download) (v20 or later recommended) - pnpm: [Package manager](https://pnpm.io/installation) used throughout the course - Vercel account: [Create one for free](https://vercel.com/signup) - Supabase account: [Create one for free](https://supabase.com) - Stripe account: [Create one for free](https://stripe.com) ### Getting Started You'll begin by deploying a pre-configured starter repo with one click: - **Deploy with Vercel** button creates your project instantly - Next.js 16, React 19, Tailwind CSS 4, and shadcn/ui already configured - Clone locally and start building immediately No boilerplate setup - you focus on the interesting parts from lesson one. ### Section 1: Setup & Auth Build complete authentication: - **Deploy Starter**: One-click deploy, clone locally, explore structure - **Supabase Setup**: Create project, configure environment variables - **Client Utilities**: Build browser and server Supabase clients - **Auth Pages**: Sign-up and sign-in with Server Actions - **Protected Routes**: Proxy for session management and route protection (Next.js 16) ### Section 2: Stripe Integration Implement subscription billing: - **Stripe SDK**: Configure server and browser Stripe clients - **Pricing Page**: Display subscription tiers from Supabase - **Checkout Flow**: Create checkout sessions and redirect to Stripe - **Subscription Management**: View active subscriptions with status - **Subscription Actions**: Customer Portal for billing self-service ### Section 3: Access Control Gate features by subscription: - **Understanding Access Control**: Subscription-based gating concepts - **Server-Side Checks**: Gate Server Components and pages - **Client-Side Checks**: Build interactive premium features - **Protected APIs**: Secure API routes with subscription validation ### Section 4: Production Ready Ship with confidence: - **Error Handling**: Loading states and graceful error recovery - **Navigation**: Complete header and sidebar with auth state - **Deploy**: Production configuration and verification ## Tech Stack - [Next.js 16](https://nextjs.org) - React framework with App Router and Turbopack - [React 19](https://react.dev) - UI library with Server Components - [Supabase](https://supabase.com) - Authentication and database - [Stripe](https://stripe.com) - Payment processing and subscriptions - [Tailwind CSS 4](https://tailwindcss.com) - Utility-first styling - [shadcn/ui](https://ui.shadcn.com) - Component library Your idea is good. Let's bring it to life. --- title: "Deploy Your Starter" description: "Deploy the pre-configured starter repo to Vercel with one click, clone it locally, and explore the Next.js 16 project structure with Tailwind CSS and shadcn/ui." canonical_url: "https://vercel.com/academy/subscription-store/deploy-your-starter" md_url: "https://vercel.com/academy/subscription-store/deploy-your-starter.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-01-08T15:24:21.025Z" content_type: "lesson" course: "subscription-store" course_title: "Launch a Subscription Store with Vercel and Stripe" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Deploy Your Starter # Deploy Your Starter Starting from scratch with auth, payments, and styling takes days. The starter repo gives you Next.js 16, React 19, Tailwind CSS 4, and shadcn/ui pre-configured so you can focus on the interesting parts. ## Outcome Deploy the starter repo to Vercel and run it locally with a working home page. ## Deploy to Vercel Click the button below to fork the starter and deploy it to Vercel: [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/vercel/academy-subscription-starter\&project-name=subscription-storefront\&repository-name=subscription-storefront) This will: 1. **Fork the repository** to your GitHub account 2. **Create a Vercel project** linked to your fork 3. **Deploy to production** with a working landing page ## Hands-on Exercise 1.1 Deploy the starter repo and verify it runs locally: **Requirements:** 1. Deploy using the "Deploy with Vercel" button 2. Name your repository (e.g., `subscription-storefront`) 3. Clone the repo to your machine 4. Install dependencies and start the dev server 5. Verify the home page loads **Implementation hints:** - The deploy button creates a new repo in your GitHub account - Skip environment variables during deploy - you'll add them in the next lesson - Use `pnpm` as your package manager for consistency with the course ## Try It 1. **Deploy to Vercel:** - Click the Deploy button above - Connect your GitHub account if prompted - Name your repository and click Deploy - Wait for the build to complete and visit your live URL 2. **Clone locally:** ```bash git clone https://github.com/YOUR_USERNAME/subscription-storefront.git cd subscription-storefront pnpm install ``` 3. **Start the dev server:** ```bash pnpm dev ``` 4. **Verify output:** ``` ▲ Next.js 16.0.10 - Local: http://localhost:3000 ✓ Starting... ✓ Ready in 1.2s ``` 5. **Open ** - you should see the Forager's Guild home page ## Commit No commit needed - this is the initial clone. ## Done-When - [ ] Repository created in your GitHub account - [ ] Project deployed to Vercel with working landing page - [ ] Repo cloned locally - [ ] `pnpm install` completed without errors - [ ] Dev server running at localhost:3000 - [ ] Home page displays Forager's Guild branding ## Solution **Step 1: Deploy with Vercel** Click the "Deploy with Vercel" button above. This opens Vercel's deploy flow: 1. Connect your GitHub account 2. Name your repository (e.g., `subscription-storefront`) 3. Skip the environment variables step for now 4. Click **Deploy** The deploy will succeed and show the Forager's Guild landing page. Auth won't work yet—you'll configure that in the next lessons. **Step 2: Clone and Install** ```bash git clone https://github.com/YOUR_USERNAME/subscription-storefront.git cd subscription-storefront pnpm install ``` **Step 3: Start Development** ```bash pnpm dev ``` Visit . You'll see the starter home page with sign-in and sign-up buttons. Clicking them navigates to the auth pages, but authentication isn't wired up yet. ## Project Structure Here's what the starter includes: ``` subscription-storefront/ ├── app/ │ ├── (auth)/ # Auth pages (sign-in, sign-up) │ ├── protected/ # Authenticated routes (account, pricing, etc.) │ ├── actions.ts # Server Actions (stubs to implement) │ ├── layout.tsx # Root layout │ └── page.tsx # Home page ├── components/ │ ├── ui/ # shadcn/ui components (button, input, etc.) │ └── ... # App components (header, sidebar, logos) ├── utils/ │ └── styles.ts # Tailwind cn() helper ├── proxy.ts # Next.js 16 proxy (to implement) ├── package.json └── .env.example ``` Key files you'll build in this section: - `utils/supabase/client.ts` - Browser-side Supabase client (you create this) - `utils/supabase/server.ts` - Server-side Supabase client (you create this) - `proxy.ts` - Route protection (you implement this) - `app/actions.ts` - Auth Server Actions (you implement this) ## Tech Stack Versions The starter uses the latest versions: | Package | Version | | --------------------- | ------- | | Next.js | 16.x | | React | 19.x | | Tailwind CSS | 4.x | | @supabase/ssr | 0.8.x | | @supabase/supabase-js | 2.x | | stripe | 17.x | --- title: "Supabase Project Setup" description: "Create a new Supabase project, locate your API keys, and configure environment variables for authentication in your Next.js 16 app." canonical_url: "https://vercel.com/academy/subscription-store/supabase-project-setup" md_url: "https://vercel.com/academy/subscription-store/supabase-project-setup.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-01-08T15:24:21.043Z" content_type: "lesson" course: "subscription-store" course_title: "Launch a Subscription Store with Vercel and Stripe" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Supabase Project Setup # Supabase Project Setup Supabase handles authentication so you don't have to build password hashing, session management, and email verification from scratch. Creating a project takes two minutes and gives you production-ready auth infrastructure. ## Outcome Create a Supabase project and configure environment variables so your app can authenticate users. ## Fast Track 1. Create a project at [supabase.com/dashboard](https://supabase.com/dashboard) 2. Copy URL and anon key from **Settings > API** 3. Add to `.env.local` and restart dev server ## Hands-on Exercise 1.2 Set up Supabase authentication for your project: **Requirements:** 1. Create a new Supabase project 2. Locate the project URL and anon key 3. Create `.env.local` with the credentials 4. Verify the dev server loads without errors **Implementation hints:** - The anon key is safe to expose in the browser - it's designed for client-side use - Never commit `.env.local` to git (it's already in `.gitignore`) - Restart the dev server after adding environment variables ## Try It 1. **Create Supabase project:** - Go to [supabase.com/dashboard](https://supabase.com/dashboard) - Click **New Project** - Enter a project name (e.g., "subscription-storefront") - Set a database password (save this somewhere secure) - Select a region close to your users - Click **Create new project** 2. **Wait for provisioning:** - Takes about 2 minutes - You'll see a spinning indicator while resources are created 3. **Get your API credentials:** - Navigate to **Settings > API** (in the left sidebar) - Copy the **Project URL** (starts with `https://`) - Copy the **anon public** key under "Project API keys" 4. **Create `.env.local`:** ```bash # In your project root cp .env.example .env.local ``` 5. **Add credentials:** ```bash title=".env.local" NEXT_PUBLIC_SUPABASE_URL=https://your-project-id.supabase.co NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIs... ``` 6. **Restart dev server:** ```bash # Stop the server (Ctrl+C) and restart pnpm dev ``` 7. **Verify:** ``` ▲ Next.js 16.0.10 - Local: http://localhost:3000 - Environments: .env.local ✓ Starting... ✓ Ready in 1.2s ``` No errors about missing environment variables means you're configured correctly. ## Commit ```bash git add -A git commit -m "feat(auth): add Supabase environment configuration" ``` ## Done-When - [ ] Supabase project created and provisioned - [ ] `.env.local` file created with `NEXT_PUBLIC_SUPABASE_URL` - [ ] `.env.local` file contains `NEXT_PUBLIC_SUPABASE_ANON_KEY` - [ ] Dev server starts without environment variable errors - [ ] `.env.local` is NOT committed to git ## Solution **Step 1: Create Supabase Project** 1. Visit [supabase.com/dashboard](https://supabase.com/dashboard) and sign in 2. Click **New Project** 3. Fill in the details: - **Name:** subscription-storefront - **Database Password:** Generate a strong password and save it - **Region:** Choose the closest to your users 4. Click **Create new project** and wait for provisioning **Step 2: Get API Credentials** Once provisioning completes: 1. Click **Settings** in the left sidebar 2. Click **API** under Configuration 3. Find these values: ``` Project URL: https://abcdefghijklmnop.supabase.co anon public: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... ``` **Step 3: Configure Environment** Create `.env.local` from the example: ```bash cp .env.example .env.local ``` Edit `.env.local`: ```bash title=".env.local" NEXT_PUBLIC_SUPABASE_URL=https://abcdefghijklmnop.supabase.co NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImFiY2RlZmdoaWprbG1ub3AiLCJyb2xlIjoiYW5vbiIsImlhdCI6MTYwMDAwMDAwMCwiZXhwIjoxOTAwMDAwMDAwfQ.xxxxx ``` **Step 4: Verify** Restart the dev server: ```bash pnpm dev ``` The server should start without errors. If you see warnings about missing environment variables, double-check your `.env.local` file. ## Understanding the Keys Supabase provides two API keys: | Key | Purpose | Safe for Browser? | | --------------- | -------------------------------------------- | ----------------- | | `anon` (public) | Client-side requests with Row Level Security | Yes | | `service_role` | Server-side admin operations, bypasses RLS | **No** | The `NEXT_PUBLIC_` prefix makes variables available in browser code. Only use this prefix for the anon key - never expose the service role key. ## Troubleshooting **"Missing environment variables" error:** - Verify `.env.local` exists in project root (not in a subdirectory) - Check for typos in variable names - Restart the dev server after changes **"Invalid API key" error:** - Make sure you copied the entire key (they're long) - Verify you're using the anon key, not the service role key - Check the Supabase dashboard to confirm the project is active **Can't find API settings:** - In the Supabase dashboard, look for the gear icon (Settings) - API settings are under Settings > API - Make sure you're in the correct project ## Advanced: Environment Variables in Vercel Once your local setup works, add the same variables to Vercel: 1. Go to your project in [vercel.com/dashboard](https://vercel.com/dashboard) 2. Click **Settings > Environment Variables** 3. Add both variables for all environments (Production, Preview, Development) 4. Redeploy to pick up the new variables This makes your deployed app work with Supabase. You'll do a full production deploy in Section 4. --- title: "Supabase Client Utilities" description: "Create browser and server Supabase clients using @supabase/ssr for SSR-compatible authentication in Next.js 16." canonical_url: "https://vercel.com/academy/subscription-store/supabase-client-utilities" md_url: "https://vercel.com/academy/subscription-store/supabase-client-utilities.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-01-08T15:24:21.057Z" content_type: "lesson" course: "subscription-store" course_title: "Launch a Subscription Store with Vercel and Stripe" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Supabase Client Utilities # Supabase Client Utilities Next.js apps run in two environments: the browser and the server. Each needs its own Supabase client with different cookie handling. The `@supabase/ssr` package provides SSR-compatible clients that keep auth state synchronized. ## Outcome Create browser and server Supabase clients that handle authentication cookies correctly in both environments. ## Fast Track 1. Implement `utils/supabase/client.ts` with `createBrowserClient` 2. Implement `utils/supabase/server.ts` with `createServerClient` 3. Verify no TypeScript errors in both files ## Hands-on Exercise 1.3 Implement the Supabase client utilities (TODO stubs are provided): **Requirements:** 1. Implement the browser client using `createBrowserClient` 2. Implement the server client using `createServerClient` with cookie handling 3. Both files export a `createSupabaseClient` function 4. Handle the async cookies API in the server client **Implementation hints:** - The browser client is simple - just pass the URL and anon key - The server client needs cookie `getAll` and `setAll` callbacks - Use `await cookies()` in Next.js 16 (it's now async) - Wrap `setAll` in try/catch for Server Components ## Try It 1. **Check the browser client:** ```bash # Open the file and verify it exports createSupabaseClient cat utils/supabase/client.ts ``` 2. **Check the server client:** ```bash cat utils/supabase/server.ts ``` 3. **Verify no TypeScript errors:** ```bash pnpm build ``` If the build succeeds (or only fails on missing Stripe config), your clients are correct. 4. **Test in dev:** ```bash pnpm dev ``` Visit - the page should load without console errors about Supabase. ## Commit ```bash git add -A git commit -m "feat(auth): add Supabase client utilities" ``` ## Done-When - [ ] `utils/supabase/client.ts` exports `createSupabaseClient` - [ ] `utils/supabase/server.ts` exports async `createSupabaseClient` - [ ] Server client handles cookie operations with `getAll` and `setAll` - [ ] No TypeScript errors in either file - [ ] Dev server runs without Supabase-related errors ## Solution **Browser Client: `utils/supabase/client.ts`** Replace the TODO stub with the implementation: ```typescript title="utils/supabase/client.ts" import { createBrowserClient } from "@supabase/ssr"; export function createSupabaseClient() { return createBrowserClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! ); } ``` The browser client is straightforward. `createBrowserClient` handles cookies automatically using `document.cookie`. **Server Client: `utils/supabase/server.ts`** Replace the TODO stub with the implementation: ```typescript title="utils/supabase/server.ts" import { createServerClient } from "@supabase/ssr"; import { cookies } from "next/headers"; export async function createSupabaseClient() { const cookieStore = await cookies(); return createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, { cookies: { getAll() { return cookieStore.getAll(); }, setAll(cookiesToSet) { try { cookiesToSet.forEach(({ name, value, options }) => cookieStore.set(name, value, options) ); } catch { // The `setAll` method was called from a Server Component. // This can be ignored if you have middleware refreshing // user sessions. } }, }, } ); } ``` The server client is more involved: 1. **`await cookies()`** - In Next.js 16, the cookies API is async 2. **`getAll()`** - Returns all cookies for Supabase to read session data 3. **`setAll()`** - Updates cookies when the session refreshes 4. **try/catch** - Server Components can't set cookies directly; the proxy handles this ## Why Two Clients? | Environment | Cookie Access | When Used | | ----------- | ----------------- | ------------------------------------------------- | | Browser | `document.cookie` | Client Components, event handlers | | Server | `cookies()` API | Server Components, Server Actions, Route Handlers | Browser code can't access the `cookies()` API, and server code can't access `document.cookie`. Each client uses the appropriate method for its environment. ## Cookie Flow Here's how authentication cookies flow through the app: ``` Browser Request ↓ proxy.ts (refreshes session, sets cookies) ↓ Server Component (reads cookies via server client) ↓ HTML Response (includes Set-Cookie headers) ↓ Browser (stores cookies, uses browser client) ``` The proxy refreshes expired sessions on every request. Server Components read the current session. The browser stores updated cookies for future requests. ## Troubleshooting **"cookies is not a function" error:** You're likely importing from the wrong package. Use: ```typescript import { cookies } from "next/headers"; ``` Not: ```typescript // Wrong! import { cookies } from "next/navigation"; ``` **"Cannot set cookies in Server Component" warning:** This is expected. The `setAll` try/catch handles this case. The proxy (`proxy.ts`) handles session refresh instead. **TypeScript errors about cookie types:** Make sure you have the latest `@supabase/ssr` version: ```bash pnpm update @supabase/ssr ``` --- title: "Sign Up and Sign In Pages" description: "Build sign-up and sign-in pages using route groups, Server Actions, and proper error handling with Supabase Auth." canonical_url: "https://vercel.com/academy/subscription-store/sign-up-and-sign-in-pages" md_url: "https://vercel.com/academy/subscription-store/sign-up-and-sign-in-pages.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-01-08T15:24:21.073Z" content_type: "lesson" course: "subscription-store" course_title: "Launch a Subscription Store with Vercel and Stripe" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Sign Up and Sign In Pages # Sign Up and Sign In Pages Authentication forms are the gateway to your app. The starter includes pre-built UI components - you'll wire them up with Server Actions to handle sign-up and sign-in securely on the server. ## Outcome Create working sign-up and sign-in pages that authenticate users with Supabase using Server Actions. ## Fast Track 1. Implement `app/actions.ts` - fill in the TODO stubs for `signUpAction`, `signInAction`, and `signOutAction` 2. The `utils/redirect.ts` utility and auth pages are already in the starter 3. Test sign-up and sign-in flows ## Hands-on Exercise 1.4 Implement the Server Actions (TODO stubs are provided): **Requirements:** 1. Implement the `signInAction` to authenticate users with Supabase 2. Implement the `signUpAction` to create new accounts 3. Implement the `signOutAction` to log users out 4. The sign-up and sign-in pages are already wired to call these actions 5. Handle errors with `encodedRedirect` for user feedback **Implementation hints:** - The starter includes `AuthSubmitButton` and `FormMessage` components - use them - Server Actions must have `"use server"` at the top of the file - Use a route group `(auth)` to share layout between auth pages - Pass error messages through URL search params with `encodedRedirect` **Components available in starter:** - `components/auth-submit-button.tsx` - Submit button with loading state - `components/form-message.tsx` - Displays success/error messages - `components/content.tsx` - Page content wrapper - `components/ui/input.tsx` - Styled form input - `components/ui/label.tsx` - Styled form label ## Try It 1. **Start the dev server:** ```bash pnpm dev ``` 2. **Test sign-up flow:** - Visit - Enter an email and password (min 6 characters) - Click Sign in (button shows loading state) - You should be redirected to `/protected` 3. **Test sign-in flow:** - Visit - Enter the credentials you created - Click Sign in - You should be redirected to `/protected` 4. **Test error handling:** - Try signing in with wrong credentials - You should see: "Invalid login credentials" 5. **Verify in Supabase:** - Go to Supabase dashboard > Authentication > Users - Your test user should appear ## Commit ```bash git add -A git commit -m "feat(auth): add sign-up and sign-in pages with Server Actions" ``` ## Done-When - [ ] `app/actions.ts` contains `signUpAction`, `signInAction`, `signOutAction` - [ ] `utils/redirect.ts` exports `encodedRedirect` function - [ ] Sign-up page renders at `/sign-up` - [ ] Sign-in page renders at `/sign-in` - [ ] Forms show loading state while submitting - [ ] Users can create accounts - [ ] Users can sign in - [ ] Error messages display for invalid credentials ## Solution ### Step 1: Verify Redirect Utility The `utils/redirect.ts` utility is already in the starter: ```typescript title="utils/redirect.ts" import { redirect } from "next/navigation"; /** * Redirects to a specified path with an encoded message as a query parameter. */ export function encodedRedirect( type: "error" | "success", path: string, message: string, ) { return redirect(`${path}?${type}=${encodeURIComponent(message)}`); } ``` This utility encodes error/success messages in the URL so they survive the redirect. ### Step 2: Implement Server Actions Replace the TODO stubs in `app/actions.ts`: ```typescript title="app/actions.ts" "use server"; import { createSupabaseClient } from "@/utils/supabase/server"; import { redirect } from "next/navigation"; import { encodedRedirect } from "@/utils/redirect"; export const signUpAction = async (formData: FormData) => { const email = formData.get("email") as string; const password = formData.get("password") as string; const client = await createSupabaseClient(); const url = process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}/protected` : "http://localhost:3000/protected"; const { error } = await client.auth.signUp({ email, password, options: { emailRedirectTo: url, }, }); if (error) { return encodedRedirect("error", "/sign-up", error.message); } return redirect("/protected"); }; export const signInAction = async (formData: FormData) => { const email = formData.get("email") as string; const password = formData.get("password") as string; const client = await createSupabaseClient(); const { error } = await client.auth.signInWithPassword({ email, password, }); if (error) { return encodedRedirect("error", "/sign-in", error.message); } return redirect("/protected"); }; export const signOutAction = async () => { const client = await createSupabaseClient(); await client.auth.signOut(); return redirect("/sign-in"); }; ``` ### Step 3: Verify Auth Layout The auth layout is already in the starter at `app/(auth)/layout.tsx`: ```typescript title="app/(auth)/layout.tsx" import Content from "@/components/content"; export default function AuthLayout({ children, }: { children: React.ReactNode; }) { return {children}; } ``` The route group `(auth)` shares this layout without adding "auth" to the URL. ### Step 4: Verify Sign-Up Page The sign-up page is already in the starter at `app/(auth)/sign-up/page.tsx`: ```typescript title="app/(auth)/sign-up/page.tsx" import { signUpAction } from "@/app/actions"; import AuthSubmitButton from "@/components/auth-submit-button"; import { FormMessage, Message } from "@/components/form-message"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import Link from "next/link"; export default async function SignUp(props: { searchParams: Promise; }) { const searchParams = await props.searchParams; return (

Join the Guild

Already a member?{" "} Sign in

); } ``` ### Step 5: Verify Sign-In Page The sign-in page is already in the starter at `app/(auth)/sign-in/page.tsx`: ```typescript title="app/(auth)/sign-in/page.tsx" import { signInAction } from "@/app/actions"; import AuthSubmitButton from "@/components/auth-submit-button"; import { FormMessage, Message } from "@/components/form-message"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import Link from "next/link"; export default async function SignIn(props: { searchParams: Promise; }) { const searchParams = await props.searchParams; return (

Welcome Back, Forager

Not a member yet?{" "} Join the Guild

); } ``` ## File Structure After This Lesson ``` app/ ├── (auth)/ │ ├── layout.tsx ← Already in starter │ ├── sign-up/ │ │ └── page.tsx ← Already in starter, calls signUpAction │ └── sign-in/ │ └── page.tsx ← Already in starter, calls signInAction ├── actions.ts ← Implemented in this lesson └── ... utils/ ├── redirect.ts ← Already in starter └── supabase/ ├── client.ts ← Implemented in lesson 1.3 └── server.ts ← Implemented in lesson 1.3 ``` ## How Server Actions Work ``` User submits form ↓ Browser POSTs to current URL ↓ Next.js routes to Server Action ↓ Action runs on server (secure) ↓ Supabase authenticates user ↓ Action calls redirect() ↓ Browser navigates to new page ``` The `"use server"` directive ensures the code never runs in the browser. Form data is sent securely to the server. ## Troubleshooting **"Invalid login credentials" on sign-up:** Supabase may require email confirmation. To disable for testing: 1. Supabase dashboard > Authentication > Providers 2. Under Email, toggle "Confirm email" off **Form submits but page doesn't redirect:** The proxy (next lesson) handles the redirect properly. For now, manually navigate to `/protected` after sign-in. **"Cannot read properties of undefined" error:** Make sure `searchParams` is awaited - it's a Promise in Next.js 16: ```typescript const searchParams = await props.searchParams; ``` --- title: "Proxy and Protected Routes" description: "Create a proxy for session management and route protection using Next.js 16's new proxy.ts pattern, replacing the legacy middleware approach." canonical_url: "https://vercel.com/academy/subscription-store/proxy-and-protected-routes" md_url: "https://vercel.com/academy/subscription-store/proxy-and-protected-routes.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-01-08T15:24:21.089Z" content_type: "lesson" course: "subscription-store" course_title: "Launch a Subscription Store with Vercel and Stripe" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Proxy and Protected Routes # Proxy and Protected Routes In Next.js 16, `proxy.ts` replaces `middleware.ts`. It runs on every request before your routes render, making it perfect for session refresh and route protection. Without it, users would be logged out when their session token expires. ## Outcome Create a proxy that refreshes auth sessions and protects the `/protected` route from unauthenticated access. ## Fast Track 1. Implement `utils/supabase/proxy.ts` with `updateSession` function 2. The root `proxy.ts` is already configured to call `updateSession` 3. The `app/protected/page.tsx` is ready to display user info once auth works ## Hands-on Exercise 1.5 Implement route protection with Next.js 16 proxy (TODO stub is provided): **Requirements:** 1. Implement `updateSession` in `utils/supabase/proxy.ts` to refresh auth cookies 2. The root `proxy.ts` is already set up to call your function 3. Redirect unauthenticated users from `/protected/*` to `/sign-in` 4. Redirect authenticated users from `/` to `/protected` 5. Verify the protected page displays user information **Implementation hints:** - The proxy creates its own Supabase client with request/response cookie handling - Always call `supabase.auth.getUser()` to refresh the session - Return the modified response to ensure cookies are set correctly - Use a matcher config to skip static files ## Try It 1. **Test unauthenticated access:** - Clear your cookies or use incognito - Visit - You should be redirected to `/sign-in` 2. **Test authenticated redirect:** - Sign in at `/sign-in` - Visit (root) - You should be redirected to `/protected` 3. **Test protected page:** - While signed in, visit `/protected` - You should see your email and user ID - You should see a Sign Out button 4. **Test sign out:** - Click Sign Out - You should be redirected to `/sign-in` - Visiting `/protected` should redirect to `/sign-in` ## Commit ```bash git add -A git commit -m "feat(auth): add proxy and protected routes" ``` ## Done-When - [ ] `utils/supabase/proxy.ts` exports `updateSession` function - [ ] `proxy.ts` exists at project root - [ ] Unauthenticated users redirected from `/protected/*` to `/sign-in` - [ ] Authenticated users redirected from `/` to `/protected` - [ ] Protected page displays user email and ID - [ ] Session persists across page refreshes - [ ] Sign out works and redirects to `/sign-in` ## Solution ### Step 1: Implement Session Update Utility Replace the TODO stub in `utils/supabase/proxy.ts`: ```typescript title="utils/supabase/proxy.ts" import { createServerClient } from "@supabase/ssr"; import { NextResponse, type NextRequest } from "next/server"; export async function updateSession(request: NextRequest) { let supabaseResponse = NextResponse.next({ request, }); const supabase = createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, { cookies: { getAll() { return request.cookies.getAll(); }, setAll(cookiesToSet) { cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value) ); supabaseResponse = NextResponse.next({ request, }); cookiesToSet.forEach(({ name, value, options }) => supabaseResponse.cookies.set(name, value, options) ); }, }, } ); // IMPORTANT: Avoid writing any logic between createServerClient and // supabase.auth.getUser(). A simple mistake could make it very hard to debug // issues with users being randomly logged out. const user = await supabase.auth.getUser(); if (request.nextUrl.pathname.startsWith("/protected") && user.error) { return NextResponse.redirect(new URL("/sign-in", request.url)); } if (request.nextUrl.pathname === "/" && !user.error) { return NextResponse.redirect(new URL("/protected", request.url)); } // IMPORTANT: You *must* return the supabaseResponse object as it is. If you're // creating a new response object with NextResponse.next() make sure to: // 1. Pass the request in it, like so: // const myNewResponse = NextResponse.next({ request }) // 2. Copy over the cookies, like so: // myNewResponse.cookies.setAll(supabaseResponse.cookies.getAll()) // 3. Change the myNewResponse object to fit your needs, but avoid changing // the cookies! // 4. Finally: // return myNewResponse // If this is not done, you may be causing the browser and server to go out // of sync and terminate the user's session prematurely! return supabaseResponse; } ``` ### Step 2: Verify Root Proxy The root `proxy.ts` is already configured in the starter: ```typescript title="proxy.ts" import { type NextRequest } from "next/server"; import { updateSession } from "@/utils/supabase/proxy"; export async function proxy(request: NextRequest) { return await updateSession(request); } export const config = { matcher: [ /* * Match all request paths except for the ones starting with: * - _next/static (static files) * - _next/image (image optimization files) * - favicon.ico (favicon file) * Feel free to modify this pattern to include more paths. */ "/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)", ], }; ``` This file calls your `updateSession` function on every request. ### Step 3: Verify Protected Layout The protected layout is already in the starter at `app/protected/layout.tsx`: ```typescript title="app/protected/layout.tsx" import Content from "@/components/content"; export default function ProtectedLayout({ children, }: { children: React.ReactNode; }) { return {children}; } ``` ### Step 4: Verify Protected Page The protected page is already in the starter at `app/protected/page.tsx`: ```typescript title="app/protected/page.tsx" import { createSupabaseClient } from "@/utils/supabase/server"; import AuthPageSignOutButton from "@/components/auth-sign-out-button"; export default async function ProtectedPage() { const supabase = await createSupabaseClient(); const { data: { user }, } = await supabase.auth.getUser(); return (

My Guild Profile

Manage your guild membership

Guild Member Information

Email
{user?.email}
Member ID
{user?.id}
); } ``` The `AuthPageSignOutButton` is a pre-built client component in the starter that handles sign-out with a loading spinner. ## File Structure After This Lesson ``` subscription-storefront/ ├── proxy.ts ← Calls your updateSession function ├── app/ │ ├── (auth)/ │ │ ├── layout.tsx │ │ ├── sign-up/page.tsx │ │ └── sign-in/page.tsx │ ├── protected/ │ │ ├── layout.tsx │ │ └── page.tsx ← Displays user info │ └── actions.ts ← Implemented in lesson 1.4 └── utils/ ├── redirect.ts └── supabase/ ├── client.ts ← Implemented in lesson 1.3 ├── server.ts ← Implemented in lesson 1.3 └── proxy.ts ← Implemented in this lesson ``` ## How the Proxy Works ``` Browser Request → proxy.ts → updateSession() ↓ Create Supabase client (reads cookies from request) ↓ Call auth.getUser() (refreshes session if needed) ↓ Check protection rules ↓ ↓ /protected? Authenticated? + no user + at root? ↓ ↓ Redirect to Redirect to /sign-in /protected ↓ Return response (with updated cookies) ``` ## Why proxy.ts Instead of middleware.ts? Next.js 16 renamed middleware to proxy to clarify its purpose. The proxy runs in front of your app on every request, making it ideal for: - Session refresh (what we're doing) - Redirects based on auth state - A/B testing - Geolocation-based routing ## Troubleshooting **"Users randomly logged out":** Make sure you return the `supabaseResponse` object, not a new `NextResponse.next()`. The response contains updated cookies that must be sent to the browser. **Redirect loops:** Check your conditions in `updateSession`. Make sure you're not redirecting authenticated users away from pages they should access. **Proxy not running:** - Verify `proxy.ts` is at the project root (same level as `package.json`) - Check the matcher config includes your routes - Restart the dev server **Session not persisting:** - Check browser cookies for `sb-` prefixed cookies - Verify environment variables are correct - Make sure `auth.getUser()` is called in the proxy ## Section Complete You now have a working authentication system: - **Lesson 1.1**: Deployed starter repo - **Lesson 1.2**: Configured Supabase credentials - **Lesson 1.3**: Created browser and server clients - **Lesson 1.4**: Built sign-up and sign-in pages - **Lesson 1.5**: Protected routes with proxy Next up in Section 2: You'll integrate Stripe to add subscription billing to your storefront. --- title: "Stripe SDK Setup" description: "Configure the Stripe SDK for server and client-side usage, connecting to your Stripe account for payment processing." canonical_url: "https://vercel.com/academy/subscription-store/stripe-sdk-setup" md_url: "https://vercel.com/academy/subscription-store/stripe-sdk-setup.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-01-08T15:24:21.116Z" content_type: "lesson" course: "subscription-store" course_title: "Launch a Subscription Store with Vercel and Stripe" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Stripe SDK Setup # Stripe SDK Setup Stripe provides official SDKs for both server and browser environments. The server SDK handles secure operations like creating checkout sessions, while the browser SDK redirects users to Stripe's hosted checkout page. This lesson sets up both. ## Outcome Configure the Stripe SDK for both server and browser environments, ready to process subscriptions. ## Fast Track 1. Create a Stripe account at [stripe.com](https://stripe.com) and get API keys 2. Add `STRIPE_SECRET_KEY` and `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` to `.env.local` 3. Implement `utils/stripe/config.ts` and `utils/stripe/client.ts` ## Hands-on Exercise 2.1 Configure the Stripe SDK to work in your app: **Requirements:** 1. Create a Stripe account and access test mode API keys 2. Add secret and publishable keys to environment variables 3. Create a server-side Stripe client with proper configuration 4. Create a browser-side Stripe loader for checkout redirects **Implementation hints:** - The server SDK uses your secret key (never expose this in the browser) - The browser SDK uses your publishable key (safe to expose) - Always use test mode during development - Set the `apiVersion` to ensure consistent API behavior ## Try It 1. **Create Stripe account:** - Go to [stripe.com](https://stripe.com) and sign up - Navigate to [Developers → API keys](https://dashboard.stripe.com/test/apikeys) - You should see **Publishable key** and **Secret key** in test mode 2. **Add to environment:** ```bash title=".env.local" STRIPE_SECRET_KEY=sk_test_... NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_... ``` 3. **Restart dev server:** ```bash pnpm dev ``` 4. **Verify no errors:** The dev server should start without errors about missing Stripe configuration. ## Commit ```bash git add -A git commit -m "feat(billing): add Stripe SDK configuration" ``` ## Done-When - [ ] Stripe account created with test mode enabled - [ ] `STRIPE_SECRET_KEY` added to `.env.local` (starts with `sk_test_`) - [ ] `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` added to `.env.local` (starts with `pk_test_`) - [ ] `utils/stripe/config.ts` exports initialized `stripe` client - [ ] `utils/stripe/client.ts` exports `getStripe` function - [ ] Dev server runs without Stripe-related errors ## Solution ### Step 1: Create Stripe Account 1. Visit [stripe.com](https://stripe.com) and sign up 2. Once in the dashboard, ensure you're in **Test mode** (toggle in the top right) 3. Navigate to **Developers → API keys** 4. Copy both keys: - **Publishable key** starts with `pk_test_` - **Secret key** starts with `sk_test_` ### Step 2: Configure Environment Add the keys to your `.env.local`: ```bash title=".env.local" # Existing Supabase vars NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGci... # Add Stripe keys STRIPE_SECRET_KEY=sk_test_abc123... NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_xyz789... ``` ### Step 3: Create Server Stripe Client Update `utils/stripe/config.ts`: ```typescript title="utils/stripe/config.ts" import Stripe from "stripe"; export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY ?? "", { apiVersion: "2025-02-24.acacia", appInfo: { name: "Foragers Guild", version: "1.0.0", }, }); ``` This creates a Stripe client configured for server-side use: - **`apiVersion`** - Locks to a specific API version for stability - **`appInfo`** - Identifies your app in Stripe's dashboard and logs ### Step 4: Create Browser Stripe Loader Update `utils/stripe/client.ts`: ```typescript title="utils/stripe/client.ts" import { loadStripe, Stripe } from "@stripe/stripe-js"; let stripePromise: Promise; export const getStripe = () => { if (!stripePromise) { stripePromise = loadStripe( process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ?? "" ); } return stripePromise; }; ``` This lazy-loads the Stripe.js library: - **Singleton pattern** - Only loads once, reuses the same instance - **`loadStripe`** - Async function from `@stripe/stripe-js` - **Publishable key** - Safe to use in the browser ## File Structure After This Lesson ``` utils/ ├── supabase/ │ ├── client.ts ← From Section 1 │ ├── server.ts ← From Section 1 │ └── proxy.ts ← From Section 1 └── stripe/ ├── config.ts ← New: server Stripe client └── client.ts ← New: browser Stripe loader ``` ## Server vs Browser SDK | SDK | Package | Key Type | Use Case | | ------- | ------------------- | --------------- | ------------------------------------------ | | Server | `stripe` | Secret key | Create sessions, manage subscriptions | | Browser | `@stripe/stripe-js` | Publishable key | Redirect to checkout, collect card details | The server SDK handles sensitive operations. The browser SDK handles client-side redirects. ## Test Mode vs Live Mode | Mode | Key Prefix | Behavior | | ---- | ---------------------- | -------------------------------- | | Test | `sk_test_`, `pk_test_` | No real charges, test cards work | | Live | `sk_live_`, `pk_live_` | Real charges, real cards only | Always develop with test keys. Switch to live keys only for production deployment. ## Troubleshooting **"Invalid API key" error:** - Verify the secret key starts with `sk_test_` - Check you copied the full key without extra spaces - Ensure `.env.local` is at the project root **"Stripe is not defined" in browser:** - The browser SDK loads asynchronously - Always `await getStripe()` before using - Verify the publishable key is set **API version warnings:** - Different API versions have different response shapes - Lock to a specific version to avoid breaking changes - Update the version when you're ready to migrate --- title: "Pricing Page with Plans" description: "Build a pricing page that fetches subscription tiers from Supabase and displays them with pricing cards." canonical_url: "https://vercel.com/academy/subscription-store/pricing-page-with-plans" md_url: "https://vercel.com/academy/subscription-store/pricing-page-with-plans.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-01-08T15:24:21.129Z" content_type: "lesson" course: "subscription-store" course_title: "Launch a Subscription Store with Vercel and Stripe" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Pricing Page with Plans # Pricing Page with Plans A pricing page is the gateway to revenue. Users need to see their options clearly - what each tier offers, how much it costs, and whether they're already subscribed. Products and prices live in Stripe, but you'll query them from Supabase for fast access. ## Outcome Build a pricing page that displays subscription tiers fetched from Supabase, with current plan indicator. ## Fast Track 1. Create products and prices in the Stripe dashboard 2. Implement `getProducts()` in `utils/supabase/queries.ts` 3. Create `app/protected/pricing/page.tsx` to display plans ## Hands-on Exercise 2.2 Build a pricing page with subscription tiers: **Requirements:** 1. Create at least 2 products in Stripe dashboard (e.g., Ranger, Elder) 2. Add monthly prices for each product 3. Implement `getProducts()` to query products with prices from Supabase 4. Implement `getSubscription()` to check the user's current plan 5. Create a pricing page at `/protected/pricing` 6. Display pricing cards with name, description, and price 7. Indicate current plan if user is subscribed **Implementation hints:** - Products sync from Stripe to Supabase via webhooks (already set up) - Use Supabase joins to fetch products with their prices in one query - Format prices by dividing `unit_amount` by 100 (Stripe stores cents) - Check `price_id` against the user's subscription to show "Current Plan" ## Try It 1. **Create products in Stripe:** - Go to your [Stripe dashboard](https://dashboard.stripe.com/test/products) - Click **Add product** - Add "Ranger" with description and $9/month price - Add "Elder" with description and $29/month price 2. **Trigger webhook sync:** - Products sync automatically if webhooks are configured - For local dev, you may need to run `stripe listen` (covered in advanced section) 3. **Start dev server:** ```bash pnpm dev ``` 4. **Visit pricing page:** - Sign in to your app - Navigate to - You should see both pricing tiers 5. **Verify display:** - Product names display correctly - Prices show as `$9.00` and `$29.00` - Cards have "Select Plan" buttons ## Commit ```bash git add -A git commit -m "feat(billing): add pricing page with subscription tiers" ``` ## Done-When - [ ] At least 2 products created in Stripe dashboard - [ ] `getProducts()` queries products with prices from Supabase - [ ] `getSubscription()` queries user's active subscription - [ ] Pricing page renders at `/protected/pricing` - [ ] Prices display correctly (formatted from cents) - [ ] Current plan indicator works for subscribed users - [ ] Empty state handles no products gracefully ## Solution ### Step 1: Create Products in Stripe 1. Go to your [Stripe dashboard](https://dashboard.stripe.com/test/products) in test mode 2. Click **Add product** 3. Create two products: **Ranger Tier:** - Name: Ranger - Description: Mushroom database, seasonal guides, safe vs deadly comparisons - Add price: $9.00/month (recurring) **Elder Tier:** - Name: Elder - Description: Full archive, medicinal uses, offline field guide, submit your own finds - Add price: $29.00/month (recurring) ### Step 2: Implement Product Queries Update `utils/supabase/queries.ts` to implement the query functions: ```typescript title="utils/supabase/queries.ts" import { SupabaseClient } from "@supabase/supabase-js"; import { cache } from "react"; // Type definitions for database queries export type ProductWithPrices = { id: string; active: boolean; name: string; description: string | null; image: string | null; metadata: Record; prices: Price[]; }; export type Price = { id: string; product_id: string; active: boolean; description: string | null; unit_amount: number | null; currency: string; type: "one_time" | "recurring"; interval: "day" | "week" | "month" | "year" | null; interval_count: number | null; trial_period_days: number | null; metadata: Record; }; export type SubscriptionWithPrice = { id: string; user_id: string; status: string; metadata: Record; price_id: string; quantity: number; cancel_at_period_end: boolean; created: string; current_period_start: string; current_period_end: string; ended_at: string | null; cancel_at: string | null; canceled_at: string | null; trial_start: string | null; trial_end: string | null; prices: Price & { products: ProductWithPrices; }; }; // Get all active products with their prices export const getProducts = cache(async (supabase: SupabaseClient) => { const { data: products } = await supabase .from("products") .select("*, prices(*)") .eq("active", true) .eq("prices.active", true) .order("metadata->index") .order("unit_amount", { referencedTable: "prices" }); return (products as ProductWithPrices[]) ?? []; }); // Get user's active subscription with price and product details export const getSubscription = cache(async (supabase: SupabaseClient) => { const { data: subscription } = await supabase .from("subscriptions") .select("*, prices(*, products(*))") .in("status", ["trialing", "active"]) .maybeSingle(); return subscription as SubscriptionWithPrice | null; }); // Check if user has an active subscription export const hasActiveSubscription = cache(async (supabase: SupabaseClient) => { const { data: { user }, } = await supabase.auth.getUser(); if (!user) return false; const { data: subscription } = await supabase .from("subscriptions") .select("id, status") .eq("user_id", user.id) .in("status", ["trialing", "active"]) .maybeSingle(); return !!subscription; }); ``` Key patterns: - **`cache()`** - React's cache function deduplicates requests within a render - **Supabase joins** - `select("*, prices(*)")` fetches related data in one query - **Active filters** - Only show active products and prices ### Step 3: Create Pricing Page Update `app/protected/pricing/page.tsx`: ```typescript title="app/protected/pricing/page.tsx" import { createSupabaseClient } from "@/utils/supabase/server"; import { getProducts, getSubscription } from "@/utils/supabase/queries"; import PricingCard from "@/components/pricing-card"; export default async function PricingPage() { const supabase = await createSupabaseClient(); // Fetch products and current subscription in parallel const [products, subscription] = await Promise.all([ getProducts(supabase), getSubscription(supabase), ]); const currentPriceId = subscription?.price_id; return (

Guild Membership Tiers

Choose the membership level that matches your foraging journey

{products.length === 0 ? (

No membership tiers available yet. Check back soon!

(Make sure products are created in Stripe and synced via webhook)

) : (
{products.map((product) => ( p.id === currentPriceId )} /> ))}
)} {subscription && (

You are currently on the {subscription.prices?.products?.name} plan.

)}
); } ``` ### Step 4: Create Loading State The loading state is already in the starter at `app/protected/pricing/loading.tsx`. ## File Structure After This Lesson ``` app/protected/ ├── page.tsx ← Account page from Section 1 ├── layout.tsx ← Protected layout └── pricing/ ├── page.tsx ← Updated: pricing page └── loading.tsx ← Loading skeleton (in starter) utils/ ├── supabase/ │ ├── queries.ts ← Updated: product/subscription queries │ └── ... └── stripe/ ├── config.ts ← From lesson 2.1 └── client.ts ← From lesson 2.1 components/ └── pricing-card.tsx ← In starter (will update in 2.3) ``` ## How Product Data Flows ``` Stripe Dashboard ↓ Create product/price ↓ Webhook fires (product.created, price.created) ↓ app/api/webhooks/route.ts ↓ upsertProductRecord() / upsertPriceRecord() ↓ Supabase products/prices tables ↓ getProducts() query ↓ Pricing page displays ``` Products are the source of truth in Stripe. Supabase mirrors them for fast queries. ## Price Formatting Stripe stores prices in the smallest currency unit (cents for USD): | Stored Value | Display Value | | ------------ | ------------- | | 900 | $9.00 | | 2900 | $29.00 | | 9900 | $99.00 | Always divide by 100 and use `.toFixed(2)` for proper formatting: ```typescript const priceString = `$${(price.unit_amount / 100).toFixed(2)}`; ``` ## Troubleshooting **"No products found":** - Verify products exist in your Stripe dashboard - Check that prices are added to each product - Ensure webhooks are syncing (check Supabase tables directly) - For local dev, run `stripe listen --forward-to localhost:3000/api/webhooks` **Products exist in Stripe but not in Supabase:** - Webhooks may not be configured for your endpoint - Check the Stripe dashboard under **Developers → Webhooks** - Manually trigger sync by updating the product in Stripe **Prices show as null:** - Make sure `unit_amount` is set on each price in Stripe - Check the price type is "recurring" for subscriptions ## Advanced: Local Webhook Testing For local development, use the Stripe CLI to forward webhooks: ```bash # Install Stripe CLI brew install stripe/stripe-cli/stripe # Login to your Stripe account stripe login # Forward webhooks to your local server stripe listen --forward-to localhost:3000/api/webhooks # Copy the webhook signing secret and add to .env.local STRIPE_WEBHOOK_SECRET=whsec_... ``` This forwards Stripe events to your local webhook handler. --- title: "Stripe Checkout Flow" description: "Implement the Stripe Checkout flow for new subscriptions using a Server Action and redirect to Stripe's hosted checkout page." canonical_url: "https://vercel.com/academy/subscription-store/stripe-checkout-flow" md_url: "https://vercel.com/academy/subscription-store/stripe-checkout-flow.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-01-08T15:24:21.144Z" content_type: "lesson" course: "subscription-store" course_title: "Launch a Subscription Store with Vercel and Stripe" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Stripe Checkout Flow # Stripe Checkout Flow Stripe Checkout is a hosted payment page that handles card entry, validation, and 3D Secure authentication. Instead of building your own payment form, you redirect users to Stripe's secure page. You'll create a Server Action that generates checkout sessions and a client component that redirects to Stripe. ## Outcome Wire up the pricing cards to create checkout sessions and redirect users to Stripe Checkout for subscription purchases. ## Fast Track 1. Implement `checkoutWithStripe()` Server Action in `utils/stripe/server.ts` 2. Implement `createOrRetrieveCustomer()` in `utils/supabase/admin.ts` 3. Update `components/pricing-card.tsx` to call checkout and redirect ## Hands-on Exercise 2.3 Add checkout functionality to pricing cards: **Requirements:** 1. Implement `createOrRetrieveCustomer()` to link Supabase users to Stripe customers 2. Implement `checkoutWithStripe()` Server Action to create checkout sessions 3. Update `PricingCard` to call the Server Action on button click 4. Redirect to Stripe Checkout URL on success 5. Handle loading state during checkout creation 6. Handle errors gracefully **Implementation hints:** - Use `"use server"` directive for Server Actions - Get the authenticated user from Supabase before creating the session - Stripe needs a customer ID - create one if the user doesn't have one - Use `stripe.checkout.sessions.create()` with mode `"subscription"` - Redirect with `stripe.redirectToCheckout({ sessionId })` ## Try It 1. **Start dev server:** ```bash pnpm dev ``` 2. **Navigate to pricing:** - Sign in to your app - Go to 3. **Click "Select Plan":** - Click on any plan's "Select Plan" button - You should be redirected to Stripe Checkout 4. **Complete test purchase:** - Use Stripe test card: `4242 4242 4242 4242` - Any future expiry date (e.g., 12/34) - Any CVC (e.g., 123) - Click "Subscribe" 5. **Verify redirect:** - After payment, you should land on `/protected/subscription` - The subscription page may show your new subscription ## Commit ```bash git add -A git commit -m "feat(billing): add Stripe checkout flow" ``` ## Done-When - [ ] `createOrRetrieveCustomer()` creates or retrieves Stripe customers - [ ] `checkoutWithStripe()` Server Action creates checkout sessions - [ ] "Select Plan" button triggers checkout - [ ] Loading state shows while creating session - [ ] User redirects to Stripe Checkout page - [ ] Test card works for subscription - [ ] After payment, user returns to app ## Solution ### Step 1: Implement Customer Management Update `utils/supabase/admin.ts` to implement customer creation: ```typescript title="utils/supabase/admin.ts" {58-89} import { createClient } from "@supabase/supabase-js"; import { stripe } from "@/utils/stripe/config"; import Stripe from "stripe"; // Admin client with service role key const supabaseAdmin = createClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!, { auth: { autoRefreshToken: false, persistSession: false, }, } ); // Create a customer in Stripe const createCustomerInStripe = async (uuid: string, email: string) => { const customerData = { metadata: { supabaseUUID: uuid }, email }; const newCustomer = await stripe.customers.create(customerData); if (!newCustomer) throw new Error("Stripe customer creation failed."); return newCustomer.id; }; // Upsert customer to Supabase const upsertCustomerToSupabase = async ( uuid: string, stripeCustomerId: string ) => { const { error } = await supabaseAdmin .from("customers") .upsert([{ id: uuid, stripe_customer_id: stripeCustomerId }]); if (error) throw new Error(`Supabase customer insert/update failed: ${error.message}`); return stripeCustomerId; }; // Create or retrieve a Stripe customer export const createOrRetrieveCustomer = async ({ email, uuid, }: { email: string; uuid: string; }) => { // Check if customer exists in Supabase const { data: existingSupabaseCustomer, error: queryError } = await supabaseAdmin .from("customers") .select("*") .eq("id", uuid) .maybeSingle(); if (queryError) { throw new Error(`Supabase customer lookup failed: ${queryError.message}`); } // Retrieve Stripe customer ID or check by email let stripeCustomerId: string | undefined; if (existingSupabaseCustomer?.stripe_customer_id) { const existingStripeCustomer = await stripe.customers.retrieve( existingSupabaseCustomer.stripe_customer_id ); stripeCustomerId = existingStripeCustomer.id; } else { // Check if customer exists in Stripe by email const stripeCustomers = await stripe.customers.list({ email }); stripeCustomerId = stripeCustomers.data.length > 0 ? stripeCustomers.data[0].id : undefined; } // Create customer if needed const stripeIdToInsert = stripeCustomerId ? stripeCustomerId : await createCustomerInStripe(uuid, email); // Sync to Supabase if needed if (existingSupabaseCustomer && stripeCustomerId) { if (existingSupabaseCustomer.stripe_customer_id !== stripeCustomerId) { await supabaseAdmin .from("customers") .update({ stripe_customer_id: stripeCustomerId }) .eq("id", uuid); } return stripeCustomerId; } else { await upsertCustomerToSupabase(uuid, stripeIdToInsert); return stripeIdToInsert; } }; ``` This function: 1. Checks if the user already has a Stripe customer ID in Supabase 2. If not, checks if a customer with their email exists in Stripe 3. If still not found, creates a new customer in Stripe 4. Syncs the customer ID back to Supabase ### Step 2: Implement Checkout Server Action Update `utils/stripe/server.ts`: ```typescript title="utils/stripe/server.ts" "use server"; import { stripe } from "./config"; import { createSupabaseClient } from "@/utils/supabase/server"; import { createOrRetrieveCustomer } from "@/utils/supabase/admin"; function getURL(path: string = "") { let url = process.env.NEXT_PUBLIC_SITE_URL ?? process.env.VERCEL_URL ?? "http://localhost:3000"; // Make sure to include https:// when not localhost url = url.startsWith("http") ? url : `https://${url}`; // Remove trailing slash url = url.endsWith("/") ? url.slice(0, -1) : url; return path ? `${url}${path}` : url; } export type CheckoutResponse = { sessionId?: string; errorRedirect?: string; }; export async function checkoutWithStripe( priceId: string, redirectPath: string = "/protected/subscription" ): Promise { try { const supabase = await createSupabaseClient(); const { error, data: { user }, } = await supabase.auth.getUser(); if (error || !user) { throw new Error("Could not get user session."); } // Get or create Stripe customer let customer: string; try { customer = await createOrRetrieveCustomer({ uuid: user.id, email: user.email || "", }); } catch { throw new Error("Unable to access customer record."); } // Create checkout session const session = await stripe.checkout.sessions.create({ payment_method_types: ["card"], billing_address_collection: "required", customer, customer_update: { address: "auto", }, line_items: [ { price: priceId, quantity: 1, }, ], mode: "subscription", allow_promotion_codes: true, success_url: getURL(redirectPath), cancel_url: getURL("/protected/pricing"), }); if (session) { return { sessionId: session.id }; } else { throw new Error("Unable to create checkout session."); } } catch (error) { if (error instanceof Error) { return { errorRedirect: `/protected/pricing?error=${encodeURIComponent(error.message)}`, }; } return { errorRedirect: `/protected/pricing?error=Unknown error occurred`, }; } } ``` Key aspects: - **`"use server"`** - Marks this as a Server Action callable from the client - **`getURL()`** - Builds absolute URLs for redirects that work in any environment - **Error handling** - Returns `errorRedirect` instead of throwing, so the client can navigate ### Step 3: Update Pricing Card Update `components/pricing-card.tsx`: ```typescript title="components/pricing-card.tsx" "use client"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { ProductWithPrices } from "@/utils/supabase/queries"; import { checkoutWithStripe } from "@/utils/stripe/server"; import { getStripe } from "@/utils/stripe/client"; import { useState } from "react"; import { useRouter } from "next/navigation"; interface PricingCardProps { product: ProductWithPrices; isCurrentPlan: boolean; } export default function PricingCard({ product, isCurrentPlan }: PricingCardProps) { const [isLoading, setIsLoading] = useState(false); const router = useRouter(); function getCurrencySymbol(currency: string) { switch (currency?.toLowerCase()) { case "usd": return "$"; case "eur": return "€"; case "gbp": return "£"; case "cad": case "aud": return "$"; default: return currency?.toUpperCase() || "$"; } } async function handleSelectPlan(priceId: string) { setIsLoading(true); try { const { sessionId, errorRedirect } = await checkoutWithStripe(priceId); if (errorRedirect) { router.push(errorRedirect); return; } if (sessionId) { const stripe = await getStripe(); await stripe?.redirectToCheckout({ sessionId }); } } catch (error) { console.error("Checkout error:", error); } finally { setIsLoading(false); } } // Get the monthly price (or first available price) const price = product.prices?.find((p) => p.interval === "month") || product.prices?.[0]; if (!price) { return null; } const { name, description } = product; const symbol = getCurrencySymbol(price.currency); const priceString = price.unit_amount ? `${symbol}${(price.unit_amount / 100).toFixed(2)}` : "Custom"; return (

{name}

{priceString} {price.interval && ( /{price.interval} )}
{description && (

{description}

)}
); } ``` The flow: 1. User clicks "Select Plan" 2. `handleSelectPlan` calls the Server Action 3. Server Action creates a Stripe checkout session 4. Client loads Stripe.js and redirects to checkout ## How Checkout Works ``` User clicks "Select Plan" ↓ handleSelectPlan(priceId) ↓ checkoutWithStripe() Server Action ↓ createOrRetrieveCustomer() → Stripe API ↓ stripe.checkout.sessions.create() ↓ Returns { sessionId } ↓ getStripe() → Load Stripe.js ↓ stripe.redirectToCheckout({ sessionId }) ↓ Stripe Checkout Page ↓ User enters payment details ↓ Stripe processes payment ↓ Webhook: checkout.session.completed ↓ manageSubscriptionStatusChange() ↓ Subscription saved to Supabase ↓ Redirect to success_url (/protected/subscription) ``` ## Stripe Test Cards Use these test cards during development: | Card Number | Scenario | | --------------------- | ----------------------------- | | `4242 4242 4242 4242` | Successful payment | | `4000 0000 0000 3220` | 3D Secure required | | `4000 0000 0000 9995` | Declined (insufficient funds) | Always use: - Any future expiry date (e.g., `12/34`) - Any 3-digit CVC (e.g., `123`) - Any billing postal code (e.g., `12345`) ## File Structure After This Lesson ``` utils/ ├── stripe/ │ ├── config.ts ← Server Stripe client │ ├── client.ts ← Browser Stripe loader │ └── server.ts ← Updated: checkout Server Action └── supabase/ ├── admin.ts ← Updated: customer management └── ... components/ └── pricing-card.tsx ← Updated: checkout flow ``` ## Troubleshooting **Button doesn't respond to clicks:** - Check browser console for JavaScript errors - Verify `isLoading` and `isCurrentPlan` aren't blocking the click - Ensure Server Action is properly exported **"Could not get user session" error:** - User must be signed in before checkout - Verify the Supabase session is valid - Check cookies are being sent with the request **Redirect goes to wrong URL:** - Verify `NEXT_PUBLIC_SITE_URL` or `VERCEL_URL` is set correctly - Check that the redirect path exists in your app **Stripe page shows "Invalid session":** - The checkout session may have expired (they last 24 hours) - Try creating a new checkout session - Verify the price ID is valid in Stripe **Subscription doesn't appear after checkout:** - Check that webhooks are configured and running - Verify the webhook handler is processing `checkout.session.completed` - Check Supabase for the subscription record --- title: "Subscription Management" description: "Build a subscription management page that displays active subscriptions with plan details, pricing, and status indicators." canonical_url: "https://vercel.com/academy/subscription-store/subscription-management-page" md_url: "https://vercel.com/academy/subscription-store/subscription-management-page.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-01-08T15:24:21.161Z" content_type: "lesson" course: "subscription-store" course_title: "Launch a Subscription Store with Vercel and Stripe" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Subscription Management # Subscription Management Page After checkout, users land on the subscription page. They need to see what they're paying for, when it renews, and whether it's active. This page becomes the hub for managing their billing relationship with your app. ## Outcome Build a subscription management page that displays the user's active subscription with plan details, price, and status. ## Fast Track 1. Create `app/protected/subscription/page.tsx` 2. Fetch subscription with `getSubscription()` from `utils/supabase/queries.ts` 3. Display subscription card with status indicator ## Hands-on Exercise 2.4 Build a subscription management page: **Requirements:** 1. Create subscription page at `/protected/subscription` 2. Fetch subscription using `getSubscription()` (implemented in lesson 2.2) 3. Display subscription card with: - Product name - Price per interval - Status indicator (active, trialing, cancelling) - Current billing period dates 4. Add loading state skeleton 5. Handle empty state (no subscription) **Implementation hints:** - Use the Server Component pattern with Supabase client - `getSubscription()` returns subscription with nested price and product - Format dates using `toLocaleDateString()` - Check `cancel_at_period_end` for "cancelling" state ## Try It 1. **Ensure you have a subscription:** - Complete a test checkout from lesson 2.3 - Or create one via the Stripe dashboard 2. **Visit subscription page:** - Go to - You should see your active subscription 3. **Verify display:** - Product name shows correctly - Price shows as `$X.XX/month` - Status indicator is green for active - Billing period dates display 4. **Test loading state:** - Refresh the page - Loading skeleton should appear briefly ## Commit ```bash git add -A git commit -m "feat(billing): add subscription management page" ``` ## Done-When - [ ] Subscription page renders at `/protected/subscription` - [ ] Active subscription displays with details - [ ] Product name and price shown - [ ] Status indicator shows correct state - [ ] Billing period dates formatted correctly - [ ] Loading skeleton displays while fetching - [ ] Empty state handles no subscription ## Solution ### Step 1: Create Loading State The loading state is already in the starter at `app/protected/subscription/loading.tsx`. ### Step 2: Create Subscription Page Update `app/protected/subscription/page.tsx`: ```typescript title="app/protected/subscription/page.tsx" import { Card } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import Link from "next/link"; import { createSupabaseClient } from "@/utils/supabase/server"; import { getSubscription } from "@/utils/supabase/queries"; export default async function Page() { const supabase = await createSupabaseClient(); const { data: { user }, } = await supabase.auth.getUser(); const subscription = await getSubscription(supabase); const formatDate = (dateString: string) => { return new Date(dateString).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric", }); }; const formatPrice = (amount: number | null, currency: string) => { if (!amount) return "N/A"; return new Intl.NumberFormat("en-US", { style: "currency", currency: currency || "usd", minimumFractionDigits: 0, }).format(amount / 100); }; return (

My Membership

Manage your guild membership

{!subscription ? (

No Active Membership

You haven't joined a membership tier yet. Visit the Membership page to choose a tier and unlock guild benefits.

) : (

{subscription.prices?.products?.name || "Subscription"}

{subscription.status}

Price:{" "} {formatPrice( subscription.prices?.unit_amount, subscription.prices?.currency )} /{subscription.prices?.interval}

Current period:{" "} {formatDate(subscription.current_period_start)} -{" "} {formatDate(subscription.current_period_end)}

{subscription.cancel_at_period_end && (

Your subscription will cancel at the end of the current billing period.

)}
{/* Subscription actions will be added in lesson 2.5 */}
)}

Signed in as: {user?.email}

); } ``` Key patterns: - **Server Component** - Fetches data directly without client-side state - **`getSubscription()`** - Reuses the query from lesson 2.2 - **Date formatting** - `toLocaleDateString()` for human-readable dates - **Price formatting** - `Intl.NumberFormat` for currency display - **Status badge** - Visual indicator of subscription state ## Subscription Status States | Status | Visual | Meaning | | ---------- | ------------ | ---------------------------- | | `active` | Green badge | Normal active subscription | | `trialing` | Blue badge | In free trial period | | `past_due` | Yellow badge | Payment failed, grace period | | `canceled` | Not shown | Subscription ended | The `cancel_at_period_end` flag indicates the user has cancelled but still has access until the period ends. ## File Structure After This Lesson ``` app/protected/ ├── page.tsx ← Account page ├── layout.tsx ← Protected layout ├── pricing/ │ ├── page.tsx ← Pricing page │ └── loading.tsx ← Loading skeleton └── subscription/ ├── page.tsx ← Updated: subscription page └── loading.tsx ← Loading skeleton (in starter) ``` ## How Data Flows ``` SubscriptionPage (Server Component) ↓ createSupabaseClient() ↓ getSubscription(supabase) ↓ Supabase query: subscriptions + prices + products ↓ Returns SubscriptionWithPrice | null ↓ Render subscription details or empty state ``` The subscription includes nested price and product data through Supabase joins. ## Troubleshooting **"No Active Membership" when you should have one:** - Verify the checkout completed successfully in Stripe dashboard - Check that webhooks processed the `checkout.session.completed` event - Query the `subscriptions` table directly in Supabase - The subscription status must be `active` or `trialing` **Price shows "N/A":** - Check that `subscription.prices.unit_amount` exists - Verify the price was synced from Stripe via webhook **Dates show as "Invalid Date":** - Ensure date fields are ISO strings in the database - Check the webhook is storing dates correctly **Status badge shows wrong color:** - Verify the subscription status in Supabase matches expected values - Check the conditional rendering logic for your status --- title: "Subscription Actions" description: "Implement subscription management actions by redirecting users to Stripe's Customer Portal for billing self-service." canonical_url: "https://vercel.com/academy/subscription-store/subscription-actions" md_url: "https://vercel.com/academy/subscription-store/subscription-actions.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-01-08T15:24:21.176Z" content_type: "lesson" course: "subscription-store" course_title: "Launch a Subscription Store with Vercel and Stripe" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Subscription Actions # Subscription Actions Users need control over their subscriptions - updating payment methods, viewing invoices, cancelling, or changing plans. Stripe's Customer Portal handles all of this on a hosted page, so you don't need to build these features yourself. You just redirect users there. ## Outcome Add a "Manage Subscription" button that redirects users to the Stripe Customer Portal. ## Fast Track 1. Implement `createStripePortal()` Server Action in `utils/stripe/server.ts` 2. Create `components/subscription-actions.tsx` with the button 3. Add the component to the subscription page ## Hands-on Exercise 2.5 Add subscription management via Stripe Portal: **Requirements:** 1. Implement `createStripePortal()` Server Action to create portal sessions 2. Create a client component with "Manage Subscription" button 3. Redirect to Stripe Portal URL on click 4. Show loading state during portal creation 5. Add the component to the subscription page **Implementation hints:** - Use `stripe.billingPortal.sessions.create()` to create a portal session - The portal needs the Stripe customer ID - Use `router.push()` to redirect to the portal URL - Configure your portal settings in Stripe dashboard ## Try It 1. **Configure portal in Stripe:** - Go to [Stripe Dashboard → Settings → Customer Portal](https://dashboard.stripe.com/test/settings/billing/portal) - Enable the features you want (cancel, update payment, etc.) - Save configuration 2. **Start dev server:** ```bash pnpm dev ``` 3. **Navigate to subscription:** - Sign in with an account that has a subscription - Go to 4. **Click "Manage Subscription":** - You should be redirected to Stripe's Customer Portal - The portal shows billing history, payment methods, and cancel option 5. **Return to app:** - Use the portal's "Return to..." link - You should land back on your subscription page ## Commit ```bash git add -A git commit -m "feat(billing): add subscription portal access" ``` ## Done-When - [ ] `createStripePortal()` Server Action creates portal sessions - [ ] "Manage Subscription" button appears for active subscriptions - [ ] Clicking button redirects to Stripe Customer Portal - [ ] Loading state shows while creating session - [ ] Portal shows billing management options - [ ] Return link brings user back to app ## Solution ### Step 1: Implement Portal Server Action Add `createStripePortal()` to `utils/stripe/server.ts`: ```typescript title="utils/stripe/server.ts" {32-54} "use server"; import { stripe } from "./config"; import { createSupabaseClient } from "@/utils/supabase/server"; import { createOrRetrieveCustomer } from "@/utils/supabase/admin"; function getURL(path: string = "") { let url = process.env.NEXT_PUBLIC_SITE_URL ?? process.env.VERCEL_URL ?? "http://localhost:3000"; url = url.startsWith("http") ? url : `https://${url}`; url = url.endsWith("/") ? url.slice(0, -1) : url; return path ? `${url}${path}` : url; } // ... checkoutWithStripe from lesson 2.3 ... export async function createStripePortal( currentPath: string = "/protected/subscription" ): Promise { try { const supabase = await createSupabaseClient(); const { data: { user }, } = await supabase.auth.getUser(); if (!user) { throw new Error("Could not get user session."); } const customer = await createOrRetrieveCustomer({ uuid: user.id, email: user.email || "", }); const { url } = await stripe.billingPortal.sessions.create({ customer, return_url: getURL(currentPath), }); if (!url) { throw new Error("Could not create billing portal"); } return url; } catch (error) { console.error("Error creating portal:", error); throw error; } } ``` The portal session: - **`customer`** - The Stripe customer ID (required) - **`return_url`** - Where users land after leaving the portal ### Step 2: Create Subscription Actions Component Create `components/subscription-actions.tsx`: ```typescript title="components/subscription-actions.tsx" "use client"; import { Button } from "@/components/ui/button"; import { Spinner } from "@/components/ui/spinner"; import { createStripePortal } from "@/utils/stripe/server"; import { SubscriptionWithPrice } from "@/utils/supabase/queries"; import { useState } from "react"; import { useRouter } from "next/navigation"; export default function SubscriptionActions({ subscription, }: { subscription: SubscriptionWithPrice; }) { const [isLoading, setIsLoading] = useState(false); const router = useRouter(); async function handleManageSubscription() { setIsLoading(true); try { const portalUrl = await createStripePortal(); router.push(portalUrl); } catch (error) { console.error("Error opening portal:", error); setIsLoading(false); } } return (
); } ``` Key patterns: - **Client component** - Uses `useState` and event handlers - **Server Action call** - `createStripePortal()` runs on the server - **Redirect** - `router.push()` navigates to the portal URL - **Loading state** - Shows spinner while creating session ### Step 3: Update Subscription Page Update `app/protected/subscription/page.tsx` to include the actions: ```typescript title="app/protected/subscription/page.tsx" {6,65} import { Card } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import Link from "next/link"; import { createSupabaseClient } from "@/utils/supabase/server"; import { getSubscription } from "@/utils/supabase/queries"; import SubscriptionActions from "@/components/subscription-actions"; export default async function Page() { const supabase = await createSupabaseClient(); const { data: { user }, } = await supabase.auth.getUser(); const subscription = await getSubscription(supabase); const formatDate = (dateString: string) => { return new Date(dateString).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric", }); }; const formatPrice = (amount: number | null, currency: string) => { if (!amount) return "N/A"; return new Intl.NumberFormat("en-US", { style: "currency", currency: currency || "usd", minimumFractionDigits: 0, }).format(amount / 100); }; return (

My Membership

Manage your guild membership

{!subscription ? (

No Active Membership

You haven't joined a membership tier yet. Visit the Membership page to choose a tier and unlock guild benefits.

) : ( {/* ... existing subscription details ... */} )}

Signed in as: {user?.email}

); } ``` ## How the Portal Works ``` User clicks "Manage Subscription" ↓ handleManageSubscription() ↓ createStripePortal() Server Action ↓ createOrRetrieveCustomer() → Get Stripe customer ID ↓ stripe.billingPortal.sessions.create() ↓ Returns { url: "https://billing.stripe.com/..." } ↓ router.push(url) ↓ Stripe Customer Portal ↓ User manages billing ↓ Click "Return to..." ↓ Redirect to return_url (/protected/subscription) ``` ## Stripe Portal Features Configure what users can do in [Stripe Dashboard → Customer Portal](https://dashboard.stripe.com/test/settings/billing/portal): | Feature | Description | | --------------------- | --------------------- | | Update payment method | Add/remove cards | | View invoices | Download PDF invoices | | Cancel subscription | Cancel at period end | | Switch plans | Upgrade/downgrade | | Update billing info | Change address/email | Enable only the features you want users to access. ## File Structure After This Lesson ``` utils/stripe/ ├── config.ts ← Server Stripe client ├── client.ts ← Browser Stripe loader └── server.ts ← Updated: + createStripePortal components/ ├── pricing-card.tsx ← Checkout flow └── subscription-actions.tsx ← New: portal button ``` ## Section Complete You've now built a complete Stripe integration: - **Lesson 2.1**: Configured Stripe SDK for server and browser - **Lesson 2.2**: Built pricing page with product tiers - **Lesson 2.3**: Implemented Stripe Checkout flow - **Lesson 2.4**: Created subscription management page - **Lesson 2.5**: Added Customer Portal access Next up in Section 3: You'll learn about entitlements - how to gate features based on subscription status. ## Troubleshooting **"Could not get user session" error:** - User must be signed in before accessing the portal - Verify the Supabase session is valid - Check cookies are being sent with the request **Portal shows "No active subscriptions":** - Verify the user has a subscription in Stripe - Check that the customer ID mapping is correct - Ensure the subscription was created for this customer **"Return to" link goes to wrong URL:** - Check `return_url` in `createStripePortal()` - Verify `NEXT_PUBLIC_SITE_URL` is set correctly in production **Portal doesn't show expected options:** - Configure the portal in Stripe Dashboard - Some features (like switching plans) require additional setup - Test mode and live mode have separate configurations --- title: "Understanding Access Control" description: "Understand how to gate features based on subscription status using Supabase queries and the patterns for checking access." canonical_url: "https://vercel.com/academy/subscription-store/understanding-access-control" md_url: "https://vercel.com/academy/subscription-store/understanding-access-control.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-01-08T15:24:21.201Z" content_type: "lesson" course: "subscription-store" course_title: "Launch a Subscription Store with Vercel and Stripe" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Understanding Access Control # Understanding Access Control Users pay for access. You need to gate premium features to subscribers only. This section covers patterns for checking subscription status and controlling access to features, pages, and API routes. ## Outcome Understand the subscription-based access control pattern and when to use server vs client checks. ## Access Control Patterns You have two main approaches to feature gating: | Approach | Implementation | Trade-offs | | ------------------------- | ---------------------------- | --------------------------------------- | | Check subscription tier | `if (plan === "elder")` | Requires code changes when tiers change | | Check subscription status | `if (hasActiveSubscription)` | Simpler, binary access control | For most apps, checking if the user has *any* active subscription is sufficient. You can always add tier-specific logic later. ## When to Check | Location | Pattern | Use Case | | ---------------- | --------------------------------------- | -------------------------- | | Server Component | `await hasActiveSubscription(supabase)` | Page-level access control | | Client Component | Pass `hasAccess` as prop from server | UI conditionals | | API Route | `await hasActiveSubscription(supabase)` | Protect endpoints | | Middleware | Not recommended | Too slow for every request | Server-side checks are preferred because: - Can't be bypassed by disabling JavaScript - No flash of unauthorized content - Keeps subscription logic out of the browser ## Fast Track 1. Review the `hasActiveSubscription()` function in `utils/supabase/queries.ts` 2. Understand server vs client check patterns 3. Plan which resources need protection ## The Access Control Flow ``` User requests protected resource ↓ Get user from Supabase auth ↓ Query subscriptions table ↓ Check if any subscription is active/trialing ↓ Grant or deny access ``` ## The `hasActiveSubscription` Function This function (implemented in Section 2) does the heavy lifting: ```typescript title="utils/supabase/queries.ts" export const hasActiveSubscription = cache(async (supabase: SupabaseClient) => { const { data: { user }, } = await supabase.auth.getUser(); if (!user) return false; const { data: subscription } = await supabase .from("subscriptions") .select("id, status") .eq("user_id", user.id) .in("status", ["trialing", "active"]) .maybeSingle(); return !!subscription; }); ``` This function: 1. Gets the current user from the session 2. Queries for subscriptions with `active` or `trialing` status 3. Returns `true` if any matching subscription exists 4. Uses React's `cache()` to deduplicate requests within a render ## Using the Function ```typescript // In a Server Component import { createSupabaseClient } from "@/utils/supabase/server"; import { hasActiveSubscription } from "@/utils/supabase/queries"; export default async function ProtectedPage() { const supabase = await createSupabaseClient(); const hasAccess = await hasActiveSubscription(supabase); if (!hasAccess) { return ; } return ; } ``` ## Done-When - [ ] Understand when to use server vs client checks - [ ] Know where `hasActiveSubscription` is implemented - [ ] Understand the query that checks subscription status - [ ] Know the difference between checking tier vs checking access ## Server vs Client Checks | Check Location | Use Case | Security | | ---------------- | ------------------------ | ------------- | | Server Component | Render different content | Authoritative | | Client Component | Show/hide UI elements | UX only | | API Route | Gate backend operations | Authoritative | **Server-side** checks can't be bypassed - the user never receives the premium content. **Client-side** checks improve UX (disabled buttons, upgrade prompts) but aren't secure alone. ## Tier-Specific Access (Advanced) If you need tier-specific logic, use `getSubscription()` instead: ```typescript const subscription = await getSubscription(supabase); const productName = subscription?.prices?.products?.name; if (productName === "Elder") { // Elder-only features } else if (productName === "Ranger") { // Ranger features } ``` But start simple with binary access control and add complexity only when needed. ## Next Steps In the following lessons, you'll implement: 1. **Server-side checks** - Gate entire pages to subscribers 2. **Client-side checks** - Conditionally render UI elements 3. **API route checks** - Protect API endpoints Let's start with server-side access control. --- title: "Server-Side Checks" description: "Check subscription status in Server Components to conditionally render premium content or show upgrade prompts for users without access." canonical_url: "https://vercel.com/academy/subscription-store/server-side-subscription-checks" md_url: "https://vercel.com/academy/subscription-store/server-side-subscription-checks.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-01-08T15:24:21.215Z" content_type: "lesson" course: "subscription-store" course_title: "Launch a Subscription Store with Vercel and Stripe" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Server-Side Checks # Server-Side Subscription Checks Server-side checks are authoritative. When you check subscriptions in a Server Component, users without access never receive the premium content - it's not hidden with CSS or JavaScript, it simply doesn't exist in their response. This is the secure foundation for access control. ## Outcome Gate Server Components based on subscription status, rendering premium content or upgrade prompts. ## Fast Track 1. Create `app/protected/paid-content/page.tsx` 2. Check subscription with `hasActiveSubscription(supabase)` 3. Render premium content or upgrade prompt based on result ## Hands-on Exercise 3.2 Build a paid content page with server-side subscription checks: **Requirements:** 1. Create paid content page at `/protected/paid-content` 2. Check subscription status server-side with `hasActiveSubscription()` 3. Render premium content for subscribers 4. Render upgrade prompt for non-subscribers 5. Add loading skeleton **Implementation hints:** - Use the Supabase server client in an async Server Component - `hasActiveSubscription()` returns a boolean - Return early with different JSX for each state - Include a link to the pricing page in the upgrade prompt ## Try It 1. **Without subscription:** - Sign out and create a new account (or use one without a subscription) - Visit - You should see upgrade prompt with link to pricing 2. **With subscription:** - Sign in with an account that has a subscription - Visit - You should see the premium Field Guide content 3. **Verify security:** - View page source on the upgrade prompt page - The premium content markup should not be present at all ## Commit ```bash git add -A git commit -m "feat(access): add server-side subscription checks" ``` ## Done-When - [ ] Paid content page renders at `/protected/paid-content` - [ ] Subscription check runs server-side - [ ] Premium content shows for subscribers - [ ] Upgrade prompt shows for non-subscribers - [ ] Loading skeleton displays during fetch ## Solution ### Step 1: Create Loading State The loading state is already in the starter at `app/protected/paid-content/loading.tsx`. ### Step 2: Create Paid Content Page Update `app/protected/paid-content/page.tsx`: ```typescript title="app/protected/paid-content/page.tsx" import { createSupabaseClient } from "@/utils/supabase/server"; import { hasActiveSubscription } from "@/utils/supabase/queries"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import FieldGuideCard from "@/components/field-guide-card"; import Link from "next/link"; export default async function PaidContent() { const supabase = await createSupabaseClient(); const hasAccess = await hasActiveSubscription(supabase); if (!hasAccess) { return (

Rangers & Elders Only

The Field Guide is available to Ranger and Elder members. Upgrade your membership to access our complete database of edible plants, mushrooms, and foraging guides.

); } return (

Field Guide

Discover edible plants and mushrooms from our curated database

); } ``` Key patterns: - **Server Component** - No "use client" directive, runs on the server - **`hasActiveSubscription()`** - Queries Supabase for active subscriptions - **Early return** - Different JSX for each access state - **Premium component** - `FieldGuideCard` is only rendered for subscribers ### Step 3: Add Navigation Link Update your navigation or sidebar to include a link to the paid content page: ```typescript Field Guide ``` ## How Server-Side Checks Work ``` Browser requests /protected/paid-content ↓ Next.js calls PaidContent (Server Component) ↓ createSupabaseClient() → server client with session ↓ hasActiveSubscription(supabase) ↓ Query subscriptions table for active/trialing status ↓ Returns true/false ↓ Server Component renders appropriate JSX ↓ Only rendered HTML sent to browser ``` Users without access never receive the premium content - it's not in the HTML, JavaScript bundle, or anywhere in their response. ## Response Comparison **With Subscription:** ```html

Field Guide

Discover edible plants and mushrooms...

``` **Without Subscription:** ```html

Rangers & Elders Only

The Field Guide is available to Ranger and Elder members...

Upgrade Membership
``` The premium content simply doesn't exist in the second response. ## File Structure After This Lesson ``` app/protected/ ├── page.tsx ← Account page ├── layout.tsx ← Protected layout ├── pricing/ │ ├── page.tsx │ └── loading.tsx ├── subscription/ │ ├── page.tsx │ └── loading.tsx └── paid-content/ ├── page.tsx ← Updated: subscription-gated page └── loading.tsx ← Loading skeleton components/ └── field-guide-card.tsx ← Premium content component ``` ## Troubleshooting **Always shows upgrade prompt:** - Verify you have an active subscription (check `/protected/subscription`) - The subscription status must be `active` or `trialing` - Check the `subscriptions` table in Supabase directly **Always shows premium content:** - The user may have an active subscription you forgot about - Check subscription status in Stripe dashboard - Query the subscriptions table with the user's ID **Page shows loading skeleton forever:** - Check browser console for errors - Verify Supabase environment variables are set - Ensure the user is authenticated --- title: "Client-Side Checks" description: "Build interactive premium features in Client Components, with UI states that respond to user actions while relying on server-side security." canonical_url: "https://vercel.com/academy/subscription-store/client-side-subscription-checks" md_url: "https://vercel.com/academy/subscription-store/client-side-subscription-checks.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-01-08T15:24:21.228Z" content_type: "lesson" course: "subscription-store" course_title: "Launch a Subscription Store with Vercel and Stripe" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Client-Side Checks # Client-Side Subscription Checks Server-side checks handle security. Client-side components handle interactivity. When building features like a field guide that fetches data on button click, the client component manages UI state while the API route enforces access. This lesson builds the interactive part. ## Outcome Build a client component with interactive premium features that calls a protected API endpoint. ## Fast Track 1. Create `components/field-guide-card.tsx` 2. Add button that calls `/api/field-guide` 3. Display fetched data with loading states ## Hands-on Exercise 3.3 Build an interactive premium feature component: **Requirements:** 1. Create a client component for the field guide 2. Add a button that fetches a random foraging entry 3. Display loading state while fetching 4. Show the foraging data when complete 5. Handle API errors gracefully (including 403) **Implementation hints:** - The component doesn't check subscriptions itself - the server already did - Use `fetch('/api/field-guide', { method: 'POST' })` to get data - The API will be protected in lesson 3.4 - Store the fetched data in component state ## Try It 1. **With subscription:** - Sign in with a subscribed account - Visit - Click "Discover New Entry" - You should see a loading state, then foraging info 2. **Multiple fetches:** - Click the button again - A new random entry should appear - Previous entry is replaced 3. **Check loading state:** - The button should be disabled while fetching - Text should change to "Discovering..." ## Commit ```bash git add -A git commit -m "feat(access): add client-side premium feature component" ``` ## Done-When - [ ] FieldGuideCard component created - [ ] Button calls API endpoint - [ ] Loading state displays during fetch - [ ] Foraging data displays on success - [ ] Error messages display for failures - [ ] Component integrates with paid content page ## Solution ### Step 1: Create Field Guide Card Component Create `components/field-guide-card.tsx`: ```typescript title="components/field-guide-card.tsx" "use client"; import { Card } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { useState } from "react"; import { cn } from "@/utils/styles"; interface ForageEntry { name: string; type: string; edibility: string; season: string; habitat: string; description: string; tips: string; } interface FieldGuideCardProps { className?: string; } export default function FieldGuideCard({ className }: FieldGuideCardProps) { const [isLoading, setIsLoading] = useState(false); const [entry, setEntry] = useState(null); const [error, setError] = useState(null); async function handleDiscover() { setIsLoading(true); setError(null); try { const response = await fetch("/api/field-guide", { method: "POST", }); if (!response.ok) { if (response.status === 403) { setError("Membership required to access the Field Guide"); } else { setError("Failed to fetch entry"); } setIsLoading(false); return; } const data = await response.json(); setEntry(data); } catch (err) { setError("Network error. Please try again."); } setIsLoading(false); } const edibilityColors: Record = { edible: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200", caution: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200", "poisonous-lookalike": "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200", }; return (

Discover Foraging Entries

Explore our curated database of edible plants and mushrooms

{error && (

{error}

)} {entry && (

{entry.name}

{entry.edibility}
Type:{" "} {entry.type}
Season:{" "} {entry.season}
Habitat:{" "} {entry.habitat}

{entry.description}

Foraging Tips: {entry.tips}
)}
); } ``` ### Step 2: Create Placeholder API Route For now, create a placeholder API route that we'll protect in lesson 3.4. Create `app/api/field-guide/route.ts`: ```typescript title="app/api/field-guide/route.ts" const forageDatabase = [ { name: "Chanterelle", type: "mushroom", edibility: "edible", season: "Summer to Fall", habitat: "Oak and conifer forests, mossy areas", description: "Golden-yellow trumpet-shaped mushroom with a fruity, apricot-like aroma.", tips: "Look for false gills that fork and run down the stem.", }, { name: "Ramps (Wild Leeks)", type: "plant", edibility: "edible", season: "Early Spring", habitat: "Rich, moist deciduous forests", description: "Broad, smooth green leaves with a strong garlic-onion flavor.", tips: "Harvest sustainably by taking only one leaf per plant.", }, // ... more entries ]; export async function POST() { // TODO: Add subscription check (lesson 3.4) const randomItem = forageDatabase[Math.floor(Math.random() * forageDatabase.length)]; return Response.json(randomItem); } ``` This is intentionally unprotected for now - you'll add the subscription check in the next lesson. ## Server + Client Pattern ``` PaidContent (Server Component) ↓ Check subscription server-side ↓ If no access → render upgrade prompt (secure) ↓ If has access → render FieldGuideCard (Client Component) ↓ User clicks "Discover" ↓ fetch("/api/field-guide") → API checks subscription again ↓ Return foraging data or 403 ``` The server component controls what gets rendered. The API route provides a second layer of protection for the actual data. ## Why Not Check Client-Side? You might wonder: why not check subscriptions in the client component? **Problems with client-only checks:** - User could modify JavaScript to skip the check - API endpoint would still be accessible - Premium content would be in the JavaScript bundle **The correct pattern:** - Server Component checks → controls what renders - API Route checks → controls what operations execute - Client Component → handles UI state and interactions ## File Structure After This Lesson ``` components/ ├── field-guide-card.tsx ← New: interactive premium feature ├── pricing-card.tsx ├── subscription-actions.tsx └── ui/ └── ... app/api/ └── field-guide/ └── route.ts ← New: placeholder API (unprotected) ``` ## Troubleshooting **Button click does nothing:** - Check browser console for JavaScript errors - Verify the API route exists at `/api/field-guide` - Check Network tab for failed requests **403 error immediately:** - The API protection might already be in place - Verify you have an active subscription - Check the API route code **Data doesn't display:** - Check if the API returned valid JSON - Verify the entry state is being set - Look for React errors in console --- title: "Protected API Routes" description: "Protect API routes with subscription checks, returning appropriate errors for unauthorized access and completing the access control system." canonical_url: "https://vercel.com/academy/subscription-store/protected-api-routes" md_url: "https://vercel.com/academy/subscription-store/protected-api-routes.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-01-08T15:24:21.241Z" content_type: "lesson" course: "subscription-store" course_title: "Launch a Subscription Store with Vercel and Stripe" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Protected API Routes # Protected API Routes The client component from lesson 3.3 calls `/api/field-guide` on button click. Right now, anyone can hit that endpoint directly—even without a subscription. API routes are your last line of defense. When you check subscriptions here, you guarantee that premium operations only execute for paying users, regardless of how the request arrives. ## Outcome Protect the `/api/field-guide` route with a subscription check, returning 403 for unauthorized users. ## Fast Track 1. Update `app/api/field-guide/route.ts` with subscription check 2. Return 403 for unauthorized users 3. Test with and without a subscription ## Hands-on Exercise 3.4 Add subscription protection to the field guide API: **Requirements:** 1. Import and use the Supabase server client 2. Check subscription status before processing 3. Return 403 with text "Membership required" for non-subscribers 4. Return the foraging data for subscribers 5. Handle errors gracefully **Implementation hints:** - Use `createSupabaseClient()` and `hasActiveSubscription()` - Return early with 403 if no active subscription - The client component already handles 403 responses - Test the endpoint directly with curl to verify protection ## Try It 1. **Without subscription:** - Sign out or use an account without a subscription - Visit - (You'll be blocked by the server component) - Or test directly with curl: ```bash curl -X POST http://localhost:3000/api/field-guide ``` Expected: `Membership required` with 403 status 2. **With subscription:** - Sign in with a subscribed account - Visit - Click "Discover New Entry" - You should see foraging data 3. **Verify in terminal:** ``` POST /api/field-guide 403 12ms POST /api/field-guide 200 45ms ``` ## Commit ```bash git add -A git commit -m "feat(access): protect API route with subscription check" ``` ## Done-When - [ ] API route checks subscription status - [ ] 403 returned for non-subscribers - [ ] Foraging data returned for subscribers - [ ] Client component displays appropriate error message ## Solution ### Step 1: Update the API Route Update `app/api/field-guide/route.ts`: ```typescript title="app/api/field-guide/route.ts" import { createSupabaseClient } from "@/utils/supabase/server"; import { hasActiveSubscription } from "@/utils/supabase/queries"; const forageDatabase = [ { name: "Chanterelle", type: "mushroom", edibility: "edible", season: "Summer to Fall", habitat: "Oak and conifer forests, mossy areas", description: "Golden-yellow trumpet-shaped mushroom with a fruity, apricot-like aroma. One of the most prized edible wild mushrooms.", tips: "Look for false gills that fork and run down the stem. True chanterelles have solid flesh.", }, { name: "Ramps (Wild Leeks)", type: "plant", edibility: "edible", season: "Early Spring", habitat: "Rich, moist deciduous forests", description: "Broad, smooth green leaves with a strong garlic-onion flavor. The entire plant is edible.", tips: "Harvest sustainably by taking only one leaf per plant, leaving the bulb to regenerate.", }, { name: "Morel", type: "mushroom", edibility: "poisonous-lookalike", season: "Spring", habitat: "Burned areas, dying elms, orchards, river bottoms", description: "Honeycomb-patterned cap with a hollow interior. Highly sought after for their rich, earthy flavor.", tips: "Always slice in half to verify hollow interior. False morels have brain-like caps.", }, { name: "Chicken of the Woods", type: "mushroom", edibility: "caution", season: "Late Summer to Fall", habitat: "Dead or dying hardwood trees, especially oak", description: "Bright orange and yellow shelf fungus with a meaty texture. Tastes similar to chicken.", tips: "Only harvest from hardwoods - those on conifers can cause reactions.", }, ]; export async function POST() { const supabase = await createSupabaseClient(); const hasAccess = await hasActiveSubscription(supabase); if (!hasAccess) { return new Response("Membership required", { status: 403 }); } // Return a random item from the database const randomItem = forageDatabase[Math.floor(Math.random() * forageDatabase.length)]; return Response.json(randomItem); } ``` That's it. Three lines of protection code. ### Step 2: Verify Client Handling The client component from lesson 3.3 already handles 403 responses: ```typescript title="components/field-guide-card.tsx" {5-7} if (!response.ok) { if (response.status === 403) { setError("Membership required to access the Field Guide"); } else { setError("Failed to fetch entry"); } // ... } ``` No changes needed—the client and API are now working together. ## Defense in Depth You now have two layers of protection: ``` User visits /protected/paid-content ↓ Server Component checks subscription ↓ No access? → Render upgrade prompt (user never sees field guide) Has access? → Render FieldGuideCard ↓ User clicks "Discover" ↓ API Route checks subscription again ↓ No access? → Return 403 Has access? → Return foraging data ``` Why check twice? 1. **Server Component check** prevents UI from rendering—good UX, prevents confusion 2. **API Route check** prevents operation from executing—real security A malicious user could bypass the UI and call the API directly. The API check stops them. ## Request Flow Comparison **Unauthorized user via UI:** ``` Browser → Server Component → "Upgrade" card rendered (API never called) ``` **Unauthorized user via curl:** ``` curl → API Route → Subscription check → 403 response (Data never returned) ``` **Authorized user:** ``` Browser → Server Component → FieldGuideCard rendered User clicks → API Route → Subscription check → Foraging data returned ``` ## File Structure After This Lesson ``` app/api/ └── field-guide/ └── route.ts ← Now protected with subscription check app/protected/ ├── paid-content/ │ ├── page.tsx ← Server-side subscription check │ └── loading.tsx └── ... components/ └── field-guide-card.tsx ← Handles 403 responses ``` ## Section Complete You've now built a complete access control system: - **Lesson 3.1**: Understood subscription-based access control - **Lesson 3.2**: Implemented server-side checks for pages - **Lesson 3.3**: Built interactive premium components - **Lesson 3.4**: Protected API routes Next up in Section 4: Error handling, navigation, and deploying to production. ## Troubleshooting **403 for subscribed users:** - Verify the subscription is active (`status` is `active` or `trialing`) - Check the `subscriptions` table in Supabase - Ensure the user is signed in (check Supabase session via cookies) **200 for non-subscribed users:** - Make sure you saved the API route file - Restart the dev server if needed - Check the subscription check is running (add a console.log) - Verify the user doesn't have an active subscription you forgot about **Empty error message in UI:** - Confirm the API returns a text body with 403 - Check the client component is handling `response.status === 403` - Verify the error state is being displayed in the component --- title: "Error Handling & Loading" description: "Add loading.tsx files for Suspense boundaries, handle auth errors gracefully, and create consistent error patterns throughout the application." canonical_url: "https://vercel.com/academy/subscription-store/error-handling-and-loading-states" md_url: "https://vercel.com/academy/subscription-store/error-handling-and-loading-states.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-01-08T15:24:21.266Z" content_type: "lesson" course: "subscription-store" course_title: "Launch a Subscription Store with Vercel and Stripe" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Error Handling & Loading # Error Handling and Loading States Users notice errors before features. A blank screen during data fetch or a cryptic "Something went wrong" message erodes trust faster than a missing feature. Production-ready apps handle the unhappy path as carefully as the happy one. ## Outcome Add loading skeletons to all protected routes and implement consistent error handling patterns. ## Fast Track 1. Add `loading.tsx` to `/protected` and `/protected/account` 2. Create `app/protected/error.tsx` error boundary 3. Create `app/error.tsx` global error boundary ## Hands-on Exercise 4.1 Add comprehensive loading and error states: **Requirements:** 1. Audit protected routes for missing `loading.tsx` files 2. Add loading skeletons to routes without them 3. Create error boundary for protected area 4. Create global error boundary as fallback 5. Ensure all async operations have error handling **Implementation hints:** - Loading files use the skeleton pattern: `bg-muted animate-pulse rounded` - Error boundaries are client components with `reset` function - Match skeleton shapes to actual content layout - Error boundaries catch render errors, not event handler errors ## Try It 1. **Test loading states:** - Add `await new Promise(r => setTimeout(r, 2000))` to a page - Refresh and verify skeleton appears - Remove the delay when done 2. **Test error boundary:** - Temporarily throw an error in a Server Component - Verify error boundary catches it and shows recovery UI - Remove the error when done 3. **Verify all routes:** ``` /protected → loading.tsx ✓ /protected/pricing → loading.tsx ✓ /protected/subscription → loading.tsx ✓ /protected/paid-content → loading.tsx ✓ ``` ## Commit ```bash git add -A git commit -m "feat(ux): add error boundaries and loading states" ``` ## Done-When - [ ] All protected routes have loading.tsx files - [ ] Protected area has error.tsx boundary - [ ] Global error.tsx catches uncaught errors - [ ] Loading skeletons match content layout - [ ] Error boundaries offer recovery action ## Solution ### Step 1: Audit Existing Loading States Check which routes already have loading files: ``` app/protected/ ├── pricing/ │ └── loading.tsx ✓ exists ├── subscription/ │ └── loading.tsx ✓ exists ├── paid-content/ │ └── loading.tsx ✓ exists ├── page.tsx ✗ needs loading.tsx └── layout.tsx ``` ### Step 2: Add Protected Root Loading Create `app/protected/loading.tsx`: ```typescript title="app/protected/loading.tsx" export default function Loading() { return (
); } ``` This skeleton matches the account page layout with its two info cards. ### Step 3: Create Protected Error Boundary Create `app/protected/error.tsx`: ```typescript title="app/protected/error.tsx" "use client"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; export default function ProtectedError({ error, reset, }: { error: Error & { digest?: string }; reset: () => void; }) { return (

Something went wrong

We couldn't load this page. This might be a temporary issue.

{error.digest && (

Error ID: {error.digest}

)}
); } ``` ### Step 4: Create Global Error Boundary Create `app/error.tsx`: ```typescript title="app/error.tsx" "use client"; import { Button } from "@/components/ui/button"; export default function GlobalError({ error, reset, }: { error: Error & { digest?: string }; reset: () => void; }) { return (

Something went wrong

An unexpected error occurred. Please try again.

{error.digest && (

Error ID: {error.digest}

)}
); } ``` ### Step 5: Create Global Not Found Page Create `app/not-found.tsx`: ```typescript title="app/not-found.tsx" import { Button } from "@/components/ui/button"; import Link from "next/link"; export default function NotFound() { return (

404

Page not found

The page you're looking for doesn't exist or has been moved.

); } ``` ## How Error Boundaries Work ``` Component throws error during render ↓ React looks for nearest error.tsx ↓ Found → Render error UI with reset function Not found → Bubble up to parent ↓ Eventually hits app/error.tsx (global) ↓ User clicks "Try again" ↓ reset() re-renders the component tree ``` Error boundaries only catch: - Errors during rendering - Errors in lifecycle methods - Errors in constructors They don't catch: - Event handler errors (use try/catch) - Async errors in callbacks (use try/catch) - Server-side errors (handled differently) ## Loading State Hierarchy ``` User navigates to /protected/subscription ↓ Next.js checks for loading.tsx ↓ /protected/subscription/loading.tsx exists? Yes → Show subscription skeleton No → Check parent /protected/loading.tsx ↓ Parent loading.tsx exists? Yes → Show protected skeleton No → Check app/loading.tsx (global) ``` Each route segment can have its own loading state, or inherit from parent. ## Error Handling Patterns Summary | Location | Pattern | Handles | | ---------------- | --------------------- | ---------------------- | | Server Component | Return error JSX | Data fetch failures | | Client Component | try/catch + state | Event handler errors | | error.tsx | Error boundary | Render errors | | API Route | Return error Response | Request failures | | Server Action | Return `{ error }` | Form submission errors | ## File Structure After This Lesson ``` app/ ├── error.tsx ← New: global error boundary ├── not-found.tsx ← New: 404 page ├── protected/ │ ├── error.tsx ← New: protected error boundary │ ├── loading.tsx ← New: protected loading skeleton │ ├── page.tsx │ ├── pricing/ │ │ └── loading.tsx ← Already exists │ ├── subscription/ │ │ └── loading.tsx ← Already exists │ └── paid-content/ │ └── loading.tsx ← Already exists ``` ## Troubleshooting **Loading skeleton doesn't appear:** - The data fetch might be too fast - Add artificial delay to test: `await new Promise(r => setTimeout(r, 2000))` - Verify loading.tsx is in the correct directory - Check file is named exactly `loading.tsx` (not `Loading.tsx`) **Error boundary doesn't catch error:** - Error boundaries only catch render errors - Event handler errors need try/catch - Server-side errors are handled separately - Check if error is happening in a client component without boundary **Reset button doesn't work:** - The `reset` function re-renders the segment - If the error source persists, error will recur - For persistent errors, navigate away instead --- title: "Header and Navigation" description: "Complete the header component with auth state awareness, sign-out functionality, and build the protected area sidebar navigation." canonical_url: "https://vercel.com/academy/subscription-store/header-and-navigation" md_url: "https://vercel.com/academy/subscription-store/header-and-navigation.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-01-08T15:24:21.281Z" content_type: "lesson" course: "subscription-store" course_title: "Launch a Subscription Store with Vercel and Stripe" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Header and Navigation # Header and Navigation Navigation tells users where they are and where they can go. An auth-aware header shows different options based on login state—sign in buttons for guests, account links for users. A sidebar in the protected area helps users navigate between account, billing, and premium features. ## Outcome Understand how the auth-aware header and protected area sidebar work in the starter. ## Fast Track 1. Review `components/header.tsx` - already in the starter with auth state check 2. Review `components/protected-sidebar.tsx` - already in the starter with navigation links 3. Understand how auth-aware navigation works ## Hands-on Exercise 4.2 Review and understand the navigation system (pre-built in starter): **What's already implemented:** 1. Header shows different content based on auth state 2. User email displays when signed in 3. Sidebar in protected area links to Account, Membership, Subscription, Field Guide 4. Active state indicated on current page link 5. Field Guide link disabled until user has active subscription **Review hints:** - Header is a Server Component that checks auth with `createSupabaseClient()` - Sidebar uses an `InPageSidebar` component that handles active states - The `AuthPageSignOutButton` component is used throughout for sign-out ## Try It 1. **Test header (signed out):** - Sign out of the app - Visit - Header should show "Sign In" button 2. **Test header (signed in):** - Sign in to the app - Header should show your email and account link 3. **Test sidebar navigation:** - Visit - Sidebar should show all navigation links - Current page link should be highlighted 4. **Test active states:** - Click each sidebar link - Active indicator should update to current page ## Done-When - [ ] Understand how the Header checks auth state server-side - [ ] Understand how the sidebar gates the Field Guide link - [ ] Header shows different content when signed in vs signed out - [ ] Sidebar navigation links work correctly - [ ] Active page is visually indicated in sidebar ## Solution The header and sidebar are pre-built in the starter. Here's how they work: ### Header Component The header in `components/header.tsx` checks auth state server-side: ```typescript title="components/header.tsx" import Link from "next/link"; import { Button } from "@/components/ui/button"; import { createSupabaseClient } from "@/utils/supabase/server"; export default async function Header() { // Try to get the user, gracefully handle if Supabase isn't implemented let user = null; try { const supabase = await createSupabaseClient(); const { data } = await supabase.auth.getUser(); user = data.user; } catch { // Supabase client not implemented yet - show logged out state } return ( ); } ``` Key patterns: - Server Component uses `createSupabaseClient()` for auth check - Try/catch wrapper allows the header to work before Supabase is implemented - Conditional rendering based on `user` state ### Protected Sidebar Component The sidebar in `components/protected-sidebar.tsx` uses a reusable `InPageSidebar` component: ```typescript title="components/protected-sidebar.tsx" import InPageSidebar from "@/components/in-page-sidebar"; export default async function ProtectedSidebar() { // TODO: Uncomment when hasActiveSubscription is implemented in Section 2: // const supabase = await createSupabaseClient(); // const hasAccess = await hasActiveSubscription(supabase); const hasAccess = false; // Hardcoded until Section 2 return ( ); } ``` Key patterns: - `InPageSidebar` handles active state detection automatically - `disabled` prop gates the Field Guide until subscription is active - Will integrate with `hasActiveSubscription()` after Section 2 ### Layout Structure The layouts are already configured in the starter: **Root Layout** (`app/layout.tsx`) includes the Header in the component tree. **Protected Layout** (`app/protected/layout.tsx`) includes the sidebar: ```typescript title="app/protected/layout.tsx" import Content from "@/components/content"; import ProtectedSidebar from "@/components/protected-sidebar"; export default function ProtectedLayout({ children, }: { children: React.ReactNode; }) { return (
{children}
); } ``` ## Navigation Architecture ``` app/layout.tsx ↓
(Server Component) ↓ Check auth with createSupabaseClient() ↓ Render: 🌿 Forager's Guild | [Sign In] [Join Guild] (guest) 🌿 Forager's Guild | email | [My Guild] (signed in) app/protected/layout.tsx ↓ (Server Component) ↓ handles active state ↓ Render navigation links with active indicators ``` ## File Structure These components are pre-built in the starter: ``` components/ ├── header.tsx ← Auth-aware header ├── protected-sidebar.tsx ← Protected area sidebar ├── in-page-sidebar.tsx ← Reusable sidebar component ├── auth-sign-out-button.tsx ├── field-guide-card.tsx ├── pricing-card.tsx └── ui/ ├── button.tsx └── card.tsx app/ ├── layout.tsx ← Includes Header └── protected/ └── layout.tsx ← Includes ProtectedSidebar ``` ## Troubleshooting **Header shows wrong auth state:** - Server components cache aggressively in dev - Try hard refresh (Cmd+Shift+R / Ctrl+Shift+R) - Clear cookies and sign in again - Make sure you've implemented `createSupabaseClient` in Section 1 **Header shows signed-out state even when signed in:** - Verify `createSupabaseClient` is implemented (not throwing an error) - The header has a try/catch that falls back to logged-out state on error **Sidebar active state not updating:** - The `InPageSidebar` component handles this automatically - Try navigating via links rather than direct URL entry - Hard refresh if state seems stuck **Field Guide link always disabled:** - This is expected until you implement `hasActiveSubscription()` in Section 3 - Once implemented, uncomment the subscription check in `protected-sidebar.tsx` --- title: "Deploy to Production" description: "Deploy your complete subscription storefront to production with proper environment configuration and end-to-end verification." canonical_url: "https://vercel.com/academy/subscription-store/deploy-to-production" md_url: "https://vercel.com/academy/subscription-store/deploy-to-production.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-01-08T15:24:21.296Z" content_type: "lesson" course: "subscription-store" course_title: "Launch a Subscription Store with Vercel and Stripe" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Deploy to Production # Deploy to Production Test mode was training wheels. Production mode processes real payments with real money. This lesson walks through switching from test keys to live keys, configuring production environment variables, and verifying the complete subscription flow works end-to-end. ## Outcome Deploy your storefront to production with live Stripe payments and verify the complete user journey. ## Fast Track 1. Get production keys from Stripe and Supabase dashboards 2. Add production environment variables in Vercel 3. Deploy and test with a real card (then refund) ## Hands-on Exercise 4.3 Deploy to production: **Requirements:** 1. Create or switch to production Stripe keys 2. Configure production Supabase project 3. Add all environment variables in Vercel dashboard 4. Deploy to production 5. Test complete flow: sign up → subscribe → access premium → cancel **Implementation hints:** - Stripe live keys start with `sk_live_` and `pk_live_` - Create a separate Supabase project for production (recommended) - Stripe webhooks need production URLs - Test with a real card, then immediately refund **Environment variables needed:** ``` NEXT_PUBLIC_SUPABASE_URL NEXT_PUBLIC_SUPABASE_ANON_KEY SUPABASE_SERVICE_ROLE_KEY NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY STRIPE_SECRET_KEY STRIPE_WEBHOOK_SECRET ``` ## Try It 1. **Deploy to Vercel:** ```bash git push origin main ``` Or trigger deploy from Vercel dashboard. 2. **Test sign up:** - Visit your production URL - Create a new account - Verify email confirmation works 3. **Test subscription:** - Navigate to pricing page - Select a plan and checkout - Use a real card (you'll refund it) - Verify redirect to subscription page 4. **Test premium access:** - Visit premium features page - Generate a cat photo - Verify entitlement check passes 5. **Test cancellation:** - Cancel subscription from subscription page - Verify status changes to "cancelling" 6. **Refund the charge:** - Go to Stripe dashboard → Payments - Find your test payment - Issue full refund ## Commit No code changes in this lesson—configuration only. ## Done-When - [ ] Production Stripe keys in Vercel - [ ] Production Supabase credentials in Vercel - [ ] Stripe webhook configured for production URL - [ ] Deployed successfully to production - [ ] Sign up flow works - [ ] Subscription checkout works with real card - [ ] Premium features accessible after subscription - [ ] Cancellation flow works - [ ] Test payment refunded ## Solution ### Step 1: Get Production Stripe Keys 1. Go to [Stripe Dashboard](https://dashboard.stripe.com) 2. Toggle off "Test mode" in the sidebar (or click "Activate your account" if first time) 3. Complete account verification if prompted 4. Go to **Developers → API keys** 5. Copy your live keys: - Publishable key: `pk_live_...` - Secret key: `sk_live_...` ### Step 2: Create Production Stripe Products Your test mode products don't transfer to live mode. Create them again: 1. In Stripe dashboard (live mode), go to **Products** 2. Create the same products you had in test mode: - Basic Plan: $9/month - Pro Plan: $29/month 3. Copy the new price IDs ### Step 3: Configure Stripe Webhook 1. Go to **Developers → Webhooks** 2. Click **Add endpoint** 3. Enter your production URL: ``` https://your-app.vercel.app/api/webhooks/stripe ``` 4. Select events to listen for: - `checkout.session.completed` - `customer.subscription.updated` - `customer.subscription.deleted` 5. Click **Add endpoint** 6. Copy the webhook signing secret: `whsec_...` ### Step 4: Set Up Production Supabase Option A: Use same project (simpler, less isolated): - Keep using your existing Supabase project - Production and development share the same database Option B: Create new project (recommended for real apps): 1. Go to [Supabase Dashboard](https://supabase.com/dashboard) 2. Click **New Project** 3. Name it `storefront-production` 4. Choose a strong database password 5. Select a region close to your users 6. Wait for project to provision 7. Go to **Settings → API** and copy: - Project URL - `anon` public key If using Option B, also configure auth: 1. Go to **Authentication → URL Configuration** 2. Set Site URL to your production domain 3. Add redirect URLs for your production domain ### Step 5: Add Environment Variables in Vercel 1. Go to [Vercel Dashboard](https://vercel.com/dashboard) 2. Select your project 3. Go to **Settings → Environment Variables** 4. Add each variable for **Production** environment: | Variable | Value | Environment | | ------------------------------------ | ------------------------- | ----------- | | `NEXT_PUBLIC_SUPABASE_URL` | `https://xxx.supabase.co` | Production | | `NEXT_PUBLIC_SUPABASE_ANON_KEY` | `eyJ...` | Production | | `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` | `pk_live_...` | Production | | `STRIPE_SECRET_KEY` | `sk_live_...` | Production | | `STRIPE_WEBHOOK_SECRET` | `whsec_...` | Production | 5. Click **Save** for each variable ### Step 6: Deploy Trigger a new deployment to pick up the environment variables: ```bash git commit --allow-empty -m "chore: trigger production deploy" git push origin main ``` Or in Vercel dashboard: **Deployments → Redeploy** ### Step 7: Verify Deployment 1. **Check build logs:** - Go to Vercel dashboard → Deployments - Click latest deployment - Verify build succeeded 2. **Check environment:** - In deployment details, verify environment variables loaded - Check for any build warnings 3. **Test the live site:** - Visit your production URL - Verify pages load without errors - Check browser console for issues ### Step 8: End-to-End Test Perform a complete user journey: 1. **Sign up:** - Create new account with real email - Confirm email if required - Verify redirect to protected area 2. **Subscribe:** - Navigate to pricing - Click subscribe on a plan - Complete checkout with real card: - Card: Your actual card - Or use Stripe test card if still in test mode - Verify redirect to subscription page - Verify subscription shows as active 3. **Access premium:** - Navigate to premium features - Generate a cat photo - Verify it works 4. **Cancel:** - Click cancel on subscription page - Verify status changes 5. **Refund (important!):** - Go to Stripe dashboard - Find the payment - Click **Refund** → **Full refund** - Confirm refund ## Production Checklist Before announcing your launch: ``` Authentication - [ ] Sign up works with email confirmation - [ ] Sign in works - [ ] Sign out works - [ ] Password reset works (if implemented) Billing - [ ] Pricing page shows correct prices - [ ] Checkout redirects to Stripe - [ ] Webhook processes successfully - [ ] Subscription shows after checkout - [ ] Cancel/reactivate works Access Control - [ ] Premium features blocked without subscription - [ ] Premium features accessible with subscription - [ ] Entitlement checks work correctly Error Handling - [ ] Error pages display correctly - [ ] Loading states appear - [ ] API errors handled gracefully Performance - [ ] Pages load in <3 seconds - [ ] No console errors - [ ] Images optimized ``` ## Environment Variable Reference | Variable | Where to Get | Used For | | ------------------------------------ | ------------------------------ | -------------------- | | `NEXT_PUBLIC_SUPABASE_URL` | Supabase → Settings → API | Auth, database | | `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Supabase → Settings → API | Client auth | | `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` | Stripe → Developers → API Keys | Client billing | | `STRIPE_SECRET_KEY` | Stripe → Developers → API Keys | Server billing | | `STRIPE_WEBHOOK_SECRET` | Stripe → Developers → Webhooks | Webhook verification | ## Troubleshooting **Checkout fails in production:** - Verify Stripe live keys are set (not test keys) - Check Stripe dashboard for error logs - Verify products exist in live mode **Webhook not working:** - Verify webhook URL is correct in Stripe - Check webhook secret matches environment variable - Look at Stripe webhook logs for delivery status - Test with Stripe CLI: `stripe listen --forward-to localhost:3000/api/webhooks/stripe` **Auth not working:** - Verify Supabase URL and keys are correct - Check Supabase auth settings for production URL - Verify redirect URLs include production domain - Check email templates are configured **"Invalid API key" errors:** - Environment variables may not have reloaded - Redeploy to pick up new variables - Verify no typos in variable names - Check variables are set for Production environment (not just Preview) **Subscription shows but access control doesn't work:** - Check that price IDs in your code match live mode (not test mode) - Verify `hasActiveSubscription` checks are using the correct Stripe subscription status - Check browser console for API errors when loading subscription data ## What's Next Congratulations! You've built and deployed a complete subscription storefront with: - User authentication with Supabase - Subscription billing with Stripe - Subscription-based access control - Production-ready error handling and loading states - Complete navigation and UI **Potential enhancements:** - Add more subscription tiers - Implement usage-based billing - Add team/organization features - Build admin dashboard - Add analytics tracking - Implement email notifications You now have a solid foundation for any subscription-based SaaS product. --- title: "Building Agents with eve" description: "Build a production bike shop dispatcher agent with eve, from its first typed tool to a deployed app behind Slack, a web dashboard, real auth, and human approval." canonical_url: "https://vercel.com/academy/building-agents-with-eve" md_url: "https://vercel.com/academy/building-agents-with-eve.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-09-22T04:50:16.714Z" content_type: "course" lessons: 14 estimated_time: lesson_urls: - "https://vercel.com/academy/building-agents-with-eve/scaffold-the-dispatcher.md" - "https://vercel.com/academy/building-agents-with-eve/your-first-tool.md" - "https://vercel.com/academy/building-agents-with-eve/drive-it-over-http.md" - "https://vercel.com/academy/building-agents-with-eve/find-real-openings.md" - "https://vercel.com/academy/building-agents-with-eve/remember-the-bikes.md" - "https://vercel.com/academy/building-agents-with-eve/a-playbook-per-tier.md" - "https://vercel.com/academy/building-agents-with-eve/book-a-repair.md" - "https://vercel.com/academy/building-agents-with-eve/pause-for-a-signoff.md" - "https://vercel.com/academy/building-agents-with-eve/a-web-dashboard.md" - "https://vercel.com/academy/building-agents-with-eve/add-slack.md" - "https://vercel.com/academy/building-agents-with-eve/stamp-identity.md" - "https://vercel.com/academy/building-agents-with-eve/lock-the-doors.md" - "https://vercel.com/academy/building-agents-with-eve/deploy-agent-to-vercel.md" - "https://vercel.com/academy/building-agents-with-eve/where-to-go-next.md" --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Building Agents with eve The best bike shop employees diagnose the problem before recommending work. They quote from the shop's catalog, know which repair slots are open, and remember a customer's bikes. They also know when an expensive decision needs the customer's sign-off. An agent can handle repetitive intake, scheduling, and common questions. The bike shop is our example, but the same decisions show up in agents built for other domains. We're going to build a production bike shop dispatcher with eve, a filesystem-first framework for durable agents. Think of eve as Next.js for agents. **An agent is a directory:** instructions live in one file, tools in a folder, and channels in another. One agent can run in the terminal, over HTTP, and inside Slack without separate implementations for each surface. ## Why do we need a framework? Before Next.js, every web app made the same plumbing decisions from scratch: where routes live, how rendering works, how it deploys. Next.js made those calls so you could spend your time on the app instead of the scaffolding around it. Calling a model is the easy afternoon-demo part. Production work begins around it: deciding where instructions live, how tools are registered, and what happens when a run dies halfway through. Without a framework, each project makes those decisions again. eve provides those conventions. Instructions are Markdown, tools are TypeScript files in a folder, and the runtime handles durability, approvals, and channels. This is what "an agent is a directory" buys you: **the filename is the API.** A file at `agent/tools/lookup_service.ts` becomes a tool called `lookup_service`. A file in `channels/` gives the agent another place to run. The directory is the registry. The project is **Spoke & Mirror Cyclery's dispatcher**. By the end, it will be deployed and taking bookings. If a step doesn't line up, compare your work with the finished build at [vercel-labs/bike-shop-agent](https://github.com/vercel-labs/bike-shop-agent). Commit after each lesson so you have a checkpoint to roll back to. ## What you'll build We build the dispatcher bottom-up, then cross the gap into production: **A working agent with real tools:** - Scaffold an eve agent and give it a front-desk persona - Add typed tools that quote from a real service catalog and find open slots - Drive it over the same stable HTTP session API every eve app exposes **An agent with memory and judgment:** - Remember a customer's bikes across turns with durable session state - Load a different playbook per membership tier with a dynamic skill - Park expensive bookings for human approval, then resume the exact step **The same agent, everywhere your users are:** - Put it behind a web dashboard with `useEveAgent` - Add Slack as a channel without touching a single tool - Stamp authenticated identity at the door that drives the per-tier desk **Shipped to production:** - Replace placeholder auth with a real, fail-closed policy - Build and deploy to Vercel, then smoke-test the live agent ## Prerequisites - Node.js 24+ and npm installed - A Vercel account (free tier works) - A model credential, easiest through the Vercel AI Gateway, or a linked Vercel project - Enough terminal comfort to run `curl` ## Course sections ### Section 1: Your First Agent Scaffold the dispatcher, give it the Spoke & Mirror persona, add its first typed tool, and drive it over the HTTP session API. ### Section 2: Give It a Memory and a Brain Add a second tool, durable per-session state that remembers the customer's bikes, and a dynamic skill that changes the desk per membership tier. ### Section 3: Put a Human in the Loop Watch the agent book an expensive job unsupervised, then gate it on cost-based human approval that pauses and resumes the durable session. ### Section 4: Meet Your Users Where They Are Put the same agent behind a web dashboard and Slack without touching tool code, then stamp authenticated identity that feeds the per-tier playbook. ### Section 5: Ship It Swap placeholder auth for a real fail-closed policy, deploy the dispatcher to Vercel, and smoke-test it live. --- title: "Scaffold the Dispatcher" description: "Scaffold the dispatcher with npx eve init, watch the generic skeleton fall flat on a bike question, then pin a model in agent.ts and write the front-desk persona in instructions.md until it answers in character in the dev TUI." canonical_url: "https://vercel.com/academy/building-agents-with-eve/scaffold-the-dispatcher" md_url: "https://vercel.com/academy/building-agents-with-eve/scaffold-the-dispatcher.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-29T02:28:26.053Z" content_type: "lesson" course: "building-agents-with-eve" course_title: "Building Agents with eve" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Scaffold the Dispatcher # Scaffold the Dispatcher A new hire shows up for their first shift at the bike shop. Before they touch a wrench or sell a tune-up, they need to know one thing: who they are at that counter. Are they a robot reading prices off a screen, or a front-desk advisor who asks what the bike is *doing* before quoting anything? Our agent needs the same grounding: who it is at the counter. With eve, that identity lives in `instructions.md`, an always-on file the model reads on every turn. A clear persona lets the agent act like a trustworthy advisor before it has tools. Let's scaffold a fresh agent the way you'd start any real eve project, watch it answer like a generic chatbot, then give it a personality. For now, it only needs a model and an identity so we can talk to it in the terminal. ## Outcome A scaffolded agent that answers in the Spoke & Mirror front-desk voice in the dev TUI, before it has any tools. ## Hands-on exercise ### Scaffold it One command creates the whole project: ```bash npx eve@0.41.0 init spoke-and-mirror --channel-web-nextjs cd spoke-and-mirror ``` `init` creates a project, installs dependencies, and initializes a Git repo. The first time we run it, we'll get this message: ```text ⚠ 1 setup issue: model provider not linked · /model ``` This message is expected. eve 0.41.0's default model (`zai/glm-5.2`) routes through the Vercel AI Gateway, which needs a credential before it answers. Type `/model` to open the **Configure the agent model** menu (`↑/↓` to move, `Enter` to select, `Esc` to cancel): - **Change model:** Opens the searchable AI Gateway catalog with the current model selected. Pick one and eve rewrites the `model` field in `agent/agent.ts` after the new id resolves. You can skip the menu with `/model anthropic/claude-opus-4.8`. - **Configure provider:** Clears the setup issue. It appears in bold yellow as *Required to enable the agent* until eve finds a credential. First choose a provider. Keep **AI Gateway** for this course; choosing your own provider prints wiring instructions and leaves setup unchanged. Then choose how to connect: - **Paste an `AI_GATEWAY_API_KEY`:** Saves a static gateway key to `.env.local`. - **OR: Connect a Vercel project:** Walks through the team and project pickers, then pulls the project's environment into `.env.local`. Instead of a static key, you get a short-lived `VERCEL_OIDC_TOKEN` that authenticates gateway model ids through Vercel OIDC. If it expires, run the link again. - **Done:** Closes the menu. eve reloads `.env.local` on its own. The footer changes from the yellow warning to a connection such as `zai/glm-5.2 · AI Gateway (spoke-and-mirror)`, and the setup notice disappears. \*\*Note: A \`Session ended\` line right after linking is normal\*\* Reloading `.env.local` restarts the dev server and ends the open session. Immediately after linking, you may see `Session ended — started a new session. Earlier context was cleared`. If a turn was in progress, you might also see a stray `Missing or empty 'message' field` error. The connection still succeeded. Send the question again in the new session. With the model linked, take a look at what eve generated. The pieces that matter for this lesson: ```text spoke-and-mirror/ ├── agent/ │ ├── agent.ts # chooses the model, configures the runtime │ ├── instructions.md # the always-on persona, read every turn │ └── channels/ │ └── eve.ts # the built-in HTTP channel, shipped with every app ├── app/ # web dashboard; we'll open and customize it in 4.1 ├── next.config.ts # mounts the agent routes in Next.js └── package.json ``` There's no `agent/tools/` yet, that's intentional. You add it in 1.2 the moment you write your first tool, and eve picks it up by the folder it lives in. At this point, the scaffold gives you a model, a blank personality, and a web dashboard we will deliberately leave alone until Section 4. The catalog and tools come next. \*\*Note: Why scaffold the web channel now?\*\* eve 0.41.0 can also add Web Chat later with `eve add channel/web`, but that release's registry item requests an older `ai` range than a fresh project allows in its npm override. Scaffolding with `--channel-web-nextjs` produces the same dashboard without that install conflict. For Sections 1–3, keep using the terminal UI with `npm run dev:eve`. \*\*Note: Learn with your agent\*\* Building agents is more fun when you have an agent to help. Now that the project exists, two optional installs set that up. Run them from inside `spoke-and-mirror`. The **eve skill** teaches the coding agent beside you the framework, so it can build along: ```sh npx skills add https://github.com/vercel/eve --skill eve ``` The **Academy skill** for this course lets it walk you through each lesson: ```sh npx skills add vercel-labs/academy-skills \ --skill building-agents-with-eve -y ``` Both are optional, but half of building with eve is learning to drive an agent, so you may as well start now. ### Chat with the default dispatcher Once the TUI is ready, let's ask our generic agent a question a customer would actually ask: \*\*Note: The dispatcher response will vary based on the model\*\* If you need to run eve again, type `npm run dev:eve` to start over. ```text you type > my rear bike brake feels spongy and kind of honks on the way down the hill dispatcher response (with slight variations based on the model) > That sounds like it could be a few things, possibly worn brake pads, contaminated rotors, or air in the line if it's hydraulic. You could try cleaning the rotors, replacing the pads, or bleeding the brakes. If you're not comfortable doing it yourself, take it to a bike shop! ``` Helpful, technically. But that's a search engine in a trench coat. It dumps every possibility, suggests you fix it yourself, and sends you to *some other* bike shop. It has no idea it *is* the bike shop. That's because right now its entire identity is the scaffold default: ```md title="agent/instructions.md" You are a helpful assistant. ``` Of course it acts generic. We never told it who it is. Two files own the agent's starting point. Let's set both. Stop the dev server first (Ctrl-C). ## Challenge **1. Pin the model in `agent/agent.ts`.** This is where you choose the brain. The 0.41.0 scaffold starts with `zai/glm-5.2`; the front desk of a real shop deserves the sharpest diagnosis we can give it, so we'll run the dispatcher on `anthropic/claude-opus-4.8`. A gateway model id like this routes through the Vercel AI Gateway, the same credential you linked a moment ago, so switching models needs no new key. **2. Write the persona in `agent/instructions.md`.** Replace the bland default with a real front-desk advisor. Think about what makes the shop's front desk good, and write it as standing rules, not a script: - Diagnose before quoting. Ask what the bike is doing, the noise, the symptom, when it started, before naming a service. - Quote in real dollars from the catalog. Never invent a price. - Be upfront about cost. A big job needs a sign-off, and that's normal, not something to apologize for. ### Done-When - [ ] `npx eve@0.41.0 init spoke-and-mirror --channel-web-nextjs` created the project and `npm run dev:eve` boots with `0 errors, 0 warnings`. - [ ] The model provider is linked via `/model` (or `eve link` / `AI_GATEWAY_API_KEY`) and the `model provider not linked` notice has cleared. - [ ] `agent/agent.ts` exports `defineAgent` with the model pinned to `anthropic/claude-opus-4.8`. - [ ] `agent/instructions.md` describes the Spoke & Mirror front-desk advisor (diagnose-first, real-dollar quotes, upfront about cost). - [ ] In the TUI, a vague symptom gets a diagnostic question back, in character, not a generic chatbot answer. ## Solution `agent/agent.ts`: ```ts title="agent/agent.ts" import { defineAgent } from "eve"; export default defineAgent({ model: "anthropic/claude-opus-4.8", }); ``` `agent/instructions.md`: ```md title="agent/instructions.md" You are the front-desk advisor at Spoke & Mirror Cyclery. You help customers figure out what their bike needs and get it booked in. - Diagnose before you quote. Ask what the bike is actually doing (the noise, the symptom, when it started) before you name a service. - Use the tools rather than guessing. Look up real services and prices with `lookup_service`, find real openings with `check_availability`, and book with `book_repair`. - Quote in real dollars from the catalog. Never invent a price. - Remember the customer's bikes with `remember_bike`, and check `recall_bikes` before asking them to repeat details the shop already has on file. - Be upfront about cost. Big jobs need a sign-off before they're booked. That's expected, not a problem, so don't apologize for it. ``` The persona deliberately names tools that don't exist yet, including `lookup_service` and `book_repair`. Each becomes a file in `agent/tools/` as you build out the front-desk role. --- title: "Your First Tool" description: "Add the lookup_service tool with defineTool and a Zod schema so the dispatcher quotes from the real catalog instead of guessing, and watch the tool loop run." canonical_url: "https://vercel.com/academy/building-agents-with-eve/your-first-tool" md_url: "https://vercel.com/academy/building-agents-with-eve/your-first-tool.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-29T02:28:26.073Z" content_type: "lesson" course: "building-agents-with-eve" course_title: "Building Agents with eve" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Your First Tool # Your First Tool Ask the dispatcher what a brake bleed costs right now, and the answer is unpredictable. It may ask follow-up questions or confidently invent a number. The persona says to "quote in real dollars from the catalog," but the agent cannot read a catalog yet, so the model fills the gap with a guess. A tool gives the agent a typed function for retrieving an answer instead of inventing one. For this shop, the tool needs access to a catalog of services and prices. We'll drop in a ready-made catalog (hand-typing a price list teaches nothing), then build the door ourselves. In eve that door is one file, and the file's name *is* the tool's name. ## Outcome The dispatcher quotes a real price from the catalog by calling a `lookup_service` tool you wrote, instead of guessing. ## Hands-on exercise Watch the wrong version first. With no tool, ask for a price: ```text you › what's a hydraulic brake bleed run me? dispatcher › I'm having a little trouble pulling up the service catalog right now. ``` "I'd estimate." "Usually around." That's a guess dressed up as an answer, and the price isn't ours. Now give it the real thing. First, give the shop a catalog to read from. In a new terminal, from the project directory, run: ```bash curl -f --create-dirs -o agent/lib/shop.ts https://raw.githubusercontent.com/vercel-labs/bike-shop-agent/main/agent/lib/shop.ts ``` That file is deliberately minimal: an in-memory array of services with prices and time estimates, plus a couple of helpers (`listServices`, `formatUsd`) to read it. You won't hand-build the catalog because this lesson focuses on reaching data through a typed tool. You can swap in a real booking system or warehouse later without changing the tool's interface. Now create `agent/tools/lookup_service.ts`. Three pieces make a tool: - A **`description`** the model reads to decide when to call it. Write it for the model: say what it does *and* when to reach for it. - An **`inputSchema`**, a Zod object that both validates the call and types the argument you get in `execute`. Here, an optional `query` to filter the catalog. - An **`execute`** function, your code, running in the app runtime with full access to `agent/lib/`. The catalog you added does the data work. Import `listServices` and `formatUsd` from `../lib/shop.js` (note the `.js` extension, that's how Node's module resolution wants the import written even though the file is `.ts`). \*\*Note: The filename is the tool name\*\* You won't register this tool anywhere. Because the file lives at `agent/tools/lookup_service.ts`, the model sees a tool named `lookup_service`. Rename the file and you rename the tool. The directory is the configuration. Map the catalog rows down to the fields the model actually needs, and turn cents into dollars with `formatUsd` so the agent never has to do math on a price. ## Try It Restart the dev server and ask the same question: ```bash npx eve dev ``` ```text you › what's a hydraulic brake bleed run me? ⚙ lookup_service { query: "brake" } ↳ [{ id: "brake-bleed", name: "Hydraulic Brake Bleed", description: "Flush the old fluid and bleed the brakes back to a firm lever.", price: "$55.00", estMinutes: 40 }] dispatcher › A Hydraulic Brake Bleed is $55 and takes about 40 minutes. That flushes the old fluid and bleeds the brakes back to a firm lever, which is exactly what a spongy lever usually needs. Want me to check for an opening? ``` Watch the loop in the TUI: the model emitted a `lookup_service` call, eve ran your `execute`, the real row came back as a tool result, and *then* the model answered, with the catalog's number, not its own. You wrote the tool; eve drove the loop. \*\*Note: It chose the query itself\*\* Notice you never told it to search for "brake." The model read the customer's question, decided `lookup_service` fit, and picked the query argument on its own. Your job was to describe the tool well enough that it knew when and how to use it. Seeing `⚙ lookup_service` with no result, or a type error on the import? Two usual suspects: the import path needs the `.js` extension (`../lib/shop.js`), and the file must `export default` the `defineTool(...)` call. Run `eve info` to confirm eve discovered the tool, you should see `lookup_service` in the tools list. ## Done-When - [ ] `agent/tools/lookup_service.ts` default-exports `defineTool` with a `description` and Zod `inputSchema`. - [ ] `eve info` lists `lookup_service` among the tools. - [ ] Asking for a price triggers a visible `lookup_service` call in the TUI. - [ ] The quoted number matches the catalog in `agent/lib/shop.ts`, not an invented estimate. ## Solution ```ts title="agent/tools/lookup_service.ts" import { defineTool } from "eve/tools"; import { z } from "zod"; import { listServices, formatUsd } from "../lib/shop.js"; export default defineTool({ description: "Look up the repair services the shop offers, with prices and time estimates. " + "Pass a query to filter (e.g. 'brake', 'wheel'); omit it to list everything.", inputSchema: z.object({ query: z.string().optional().describe("Optional keyword to filter services."), }), async execute({ query }) { return listServices(query).map((s) => ({ id: s.id, name: s.name, description: s.description, price: formatUsd(s.priceCents), estMinutes: s.estMinutes, })); }, }); ``` The `inputSchema` validates the model's arguments and types the `{ query }` value destructured in `execute`. eve uses that schema as the model-facing contract, which requires Zod 4. The catalog in `agent/lib/shop.ts` stays deliberately small so you can focus on the typed tool that exposes it. --- title: "Drive It Over HTTP" description: "Talk to the dispatcher over eve's stable HTTP session API: start a durable session, stream the NDJSON event log, and send a follow-up to that session." canonical_url: "https://vercel.com/academy/building-agents-with-eve/drive-it-over-http" md_url: "https://vercel.com/academy/building-agents-with-eve/drive-it-over-http.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-29T02:28:26.092Z" content_type: "lesson" course: "building-agents-with-eve" course_title: "Building Agents with eve" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Drive It Over HTTP # Drive It Over HTTP The TUI (terminal user interface) is a comfortable place to test an agent, but no customer is ever going to open your terminal. A real dispatcher gets reached over the wire: from a web page, a Slack message, a phone. Before we wire up any of those surfaces, it's worth seeing the thing they all sit on top of. Every eve app exposes the same HTTP session API, regardless of the interface built on top. The TUI, Slack, and dashboard are all clients of the routes that start sessions, stream events, and send follow-ups. So let's skip the surfaces for a second and talk to the dispatcher the way every surface does, with plain `curl`. ## Outcome You start a durable session with the dispatcher over HTTP, stream its reply as events, and send a follow-up to the same session. ## Hands-on exercise With `npx eve dev` running, open a second terminal. There's nothing to write this lesson, you're learning the contract every channel speaks, so we drive it by hand. **Start a session.** Send the customer's first message: ```bash curl -X POST http://127.0.0.1:2000/eve/v1/session \ -H 'content-type: application/json' \ -d '{"message":"what does a basic tune-up cost?"}' ``` The response should look like this: ```json {"ok":true,"sessionId":"wrun_01...","status":"accepted"} ``` `accepted` means eve started the turn asynchronously. It is not the dispatcher's answer. Copy the **`sessionId`** from this response; you use it to stream the answer and to address the next message to this conversation. If zsh prints `%` immediately after the closing brace, that is just your shell prompt touching curl's newline-free JSON output; it is not part of the response. \*\*Note: Accepted is a handoff, not the answer\*\* The first request returns quickly with a session handle while the durable turn runs in the background. Seeing a response with `status: "accepted"` is expected. Attach to the stream with the returned `sessionId` to read what the dispatcher says. **Stream the session.** Point at the stream route with the id from the JSON response. `-N` tells curl not to buffer the events: ```bash curl -N http://127.0.0.1:2000/eve/v1/session//stream ``` You get newline-delimited JSON, one event per line: the turn starting, the `lookup_service` call going out, its result coming back, the assistant's text, and the turn completing. The TUI renders this raw event log as the `⚙` lines you saw earlier. **Send a follow-up.** When the session is waiting for you (`session.waiting`), post the next message to that same session ID: ```bash curl -X POST http://127.0.0.1:2000/eve/v1/session/ \ -H 'content-type: application/json' \ -d '{"message":"and how long does it take?"}' ``` The dispatcher retained the session history, so it already knows you're asking about the tune-up. ## Try It The first `POST` returns something like this: ```text { "ok": true, "sessionId": "wrun_01...", "status": "accepted" } ``` And the stream prints the turn as it happens: ```text {"type":"session.started"} {"type":"turn.started"} {"type":"actions.requested","data":{"calls":[{"name":"lookup_service","input":{"query":"tune-up"}}]}} {"type":"action.result","data":{"result":[{"name":"Basic Tune-Up","price":"$65.00","estMinutes":60}]}} {"type":"message.completed","data":{"finishReason":"stop"}} {"type":"turn.completed"} {"type":"session.waiting"} ``` That `session.waiting` at the end is the agent telling you it's done with this turn and ready for the next message. The same durable machinery will let a turn park for human approval in Section 3 and pick up days later. \*\*Note: Send one turn at a time\*\* For predictable ordering, wait for `session.waiting` before sending the next message to the same session. Fire two messages at once and you're racing the runtime, which is rarely what you want in a chat. Getting a `401`? The dev server accepts local requests, but make sure you're hitting `127.0.0.1`/`localhost` and not a deployed URL. Getting a `404` from the stream or follow-up route? Check that you copied the complete `sessionId` from the first response. ## Done-When - [ ] `POST /eve/v1/session` returns `status: "accepted"` and a `sessionId`. - [ ] Streaming the session prints NDJSON events, including the `lookup_service` call and `session.waiting`. - [ ] A follow-up `POST` to that session ID continues the conversation without repeating context. - [ ] You can explain why the first `POST` returns a handle instead of the assistant's answer. ## Solution There's no code to write here. The agent exposed these routes when you ran it, and every channel uses the same contract: | Route | What It Does | | -------------------------------- | ------------------------------------------------------------------ | | `POST /eve/v1/session` | Start a durable session; returns an accepted status + `sessionId`. | | `GET /eve/v1/session/:id/stream` | Stream the run as NDJSON events (reconnectable). | | `POST /eve/v1/session/:id` | Send a follow-up to the same durable session. | Hold onto this picture. In Section 4 you'll put a web dashboard and Slack in front of the dispatcher, and neither one invents a new way to talk to it. `useEveAgent` calls these same routes from the browser; the Slack channel calls them from a webhook. --- title: "Find Real Openings" description: "Add the check_availability tool, a second typed tool with no input, so the dispatcher offers real open repair slots instead of inventing times." canonical_url: "https://vercel.com/academy/building-agents-with-eve/find-real-openings" md_url: "https://vercel.com/academy/building-agents-with-eve/find-real-openings.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-29T02:28:26.126Z" content_type: "lesson" course: "building-agents-with-eve" course_title: "Building Agents with eve" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Find Real Openings # Find Real Openings The dispatcher can share a quote now, but the very next thing a customer says is "great, when can you take it?" Watch what happens: with no way to see the calendar, it'll cheerfully offer you "Tuesday at 2" with the same confidence it once invented prices. Maybe Tuesday's booked solid. The agent has no idea. We fixed pricing with a tool, and we'll fix the calendar the same way. In eve, giving the agent a new ability usually means adding another file to `tools/`; the framework discovers and wires it for you. This tool takes no input. ## Outcome The dispatcher offers real open repair slots by calling a `check_availability` tool, instead of guessing at times. ## Hands-on exercise You've done this shape once, so this is mostly muscle memory now. Create `agent/tools/check_availability.ts` with the same three pieces: a `description`, an `inputSchema`, and an `execute`. Looking up open slots needs no arguments, but `inputSchema` is still required. Use an empty object schema: ```ts inputSchema: z.object({}), ``` That empty object is the contract. It tells the model that the tool takes no arguments, while still satisfying the required schema. The calendar lives in `agent/lib/shop.ts` next to the catalog. Import `listOpenSlots` and return each open slot as a small object the model can read back to the customer, an `id` it can quote to a booking tool later, and a human `when` label like `"Tue 10:00am"`. \*\*Note: Return ids with the labels\*\* Give the model both the slot's `id` and its human label. The label is what it says to the customer ("Tuesday at 10"); the `id` is what it'll hand to `book_repair` in Section 3. Returning both now saves a round-trip later. ## Try It Restart and walk the customer from price to booking: ```bash npx eve dev ``` ```text you › a basic tune-up sounds good, when can you fit me in? ⚙ check_availability {} ↳ [{ slotId: "tue-10", when: "Tue 10:00am" }, { slotId: "wed-09", when: "Wed 9:00am" }, { slotId: "thu-11", when: "Thu 11:00am" }] dispatcher › I've got Tuesday at 10, Wednesday at 9, or Thursday at 11 open for a basic tune-up. Any of those work for you? ``` These openings came from the shop's calendar. The dispatcher omitted the booked Wednesday 4pm slot because `listOpenSlots()` never returned it. \*\*Note: The agent only knows what the tool returns\*\* The model can't offer a booked slot because the data never reached it. Instead of prompting it to "avoid taken slots," return only the available ones. The tool's return value defines what the agent knows for that question. No `check_availability` in the loop? Run `eve info` and confirm the tool shows up. If the agent answers without calling it, make the `description` more specific. Say that the tool lists *currently open* booking slots so the model knows when to use it. ## Done-When - [ ] `agent/tools/check_availability.ts` default-exports `defineTool` with an empty `z.object({})` schema. - [ ] `eve info` lists `check_availability`. - [ ] Asking about scheduling triggers a `check_availability` call. - [ ] The offered times match the open slots in `agent/lib/shop.ts`, and exclude booked ones. ## Solution ```ts title="agent/tools/check_availability.ts" import { defineTool } from "eve/tools"; import { z } from "zod"; import { listOpenSlots } from "../lib/shop.js"; export default defineTool({ description: "List the repair slots that are currently open for booking.", inputSchema: z.object({}), async execute() { return listOpenSlots().map((s) => ({ slotId: s.id, when: s.label })); }, }); ``` Each new ability has its own file, and the filename identifies the ability. Next, the dispatcher needs memory that lasts beyond a single answer. --- title: "Remember the Bikes" description: "Use defineState to give the dispatcher a durable garage that survives across turns, then build remember_bike and recall_bikes tools to write and read it." canonical_url: "https://vercel.com/academy/building-agents-with-eve/remember-the-bikes" md_url: "https://vercel.com/academy/building-agents-with-eve/remember-the-bikes.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-29T02:28:26.139Z" content_type: "lesson" course: "building-agents-with-eve" course_title: "Building Agents with eve" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Remember the Bikes # Remember the Bikes A regular rolls up: "It's the commuter again, the rear shifting's gone sloppy." A good front-desk advisor doesn't say "and which bike is the commuter?" They already know it's the Surly Disc Trucker with the 700c wheels, because the shop keeps a card on file. Our dispatcher has no card. Tell it about your Surly in one turn, ask it to book work on "the commuter" in the next, and it draws a blank. A tool can answer a question, but it has no memory between calls. The agent needs somewhere to keep what it has been told. `defineState` provides a durable, per-session slot that the agent can write and read across turns. You'll declare it in `agent/lib/garage.ts`, then give the dispatcher two tools that use it. ## Outcome The dispatcher saves a customer's bike and recalls it in a later turn, without asking them to repeat the details. ## Hands-on exercise First, the memory itself. Create `agent/lib/garage.ts`: ```ts title="agent/lib/garage.ts" import { defineState } from "eve/context"; export interface Bike { make: string; model: string; wheelSize?: string; notes?: string; } export interface Garage { readonly bikes: Readonly>; } export const garage = defineState("bikeshop.garage", () => ({ bikes: {}, })); ``` `defineState(name, initial)` returns a typed handle with two methods. `get()` reads the current value and runs `initial()` the first time. `update(fn)` replaces it. The value survives across the step and turn boundaries of a session, so data written on turn one is still there on turn five. Declaring the handle at module scope lets every importing tool share the same slot. Now the hands. Two tools, both importing `garage` from `../lib/garage.js`: **`remember_bike`** takes a `label` (a short name like "the commuter") plus the bike's `make`, `model`, and optional `wheelSize` and `notes`. It writes the bike into the garage with `garage.update(...)`, then returns the updated garage so the model can confirm. **`recall_bikes`** takes no input and returns `garage.get()`, so the dispatcher can check what's on file before asking the customer to repeat themselves. \*\*Warning: State is the agent's short-term memory, not a database\*\* `defineState` lives and dies with the session: it's perfect for "what has this customer told me this conversation." Anything that must outlive the session, or be shared across customers, belongs in a real database. We're remembering bikes for the length of a chat, which is exactly its job. ## Try It Save a bike in one turn, then reference it in the next, two separate turns, same session: ```text you › it's a Surly Disc Trucker, 700c wheels. call it "the commuter". ⚙ remember_bike { label: "the commuter", make: "Surly", model: "Disc Trucker", wheelSize: "700c" } dispatcher › Got it, I've saved the commuter (Surly Disc Trucker, 700c) to your file. you › the commuter's rear shifting feels sloppy. what do you recommend? ⚙ recall_bikes {} ↳ { bikes: { "the commuter": { make: "Surly", model: "Disc Trucker", wheelSize: "700c" } } } dispatcher › Sloppy rear shifting on the commuter usually means the derailleur needs adjusting, sometimes a cable. A Basic Tune-Up ($65) covers that. Want me to check openings? ``` The second turn never asked "which bike?" The dispatcher retrieved the commuter from the garage because `defineState` held the record between turns. \*\*Note: Why it persisted\*\* State checkpoints at step boundaries, using the same durability you saw as `session.waiting` in 1.3. Eve writes the value into the durable session, so a restart or mid-conversation redeploy doesn't wipe the commuter from the file. If the second turn asks "which bike?" anyway, check that both tools import the *same* handle from `../lib/garage.js`. Two `defineState` calls with the same name are still separate slots in your code. If `recall_bikes` never fires, update the persona so it checks `recall_bikes` before asking a customer to repeat details. ## Done-When - [ ] `remember_bike` writes a bike via `garage.update(...)` and returns the garage. - [ ] `recall_bikes` returns `garage.get()` with an empty input schema. - [ ] Saving a bike in one turn and referencing it in a later turn works without re-asking. - [ ] Both tools import the same `garage` handle from `../lib/garage.js`. ## Solution ```ts title="agent/tools/remember_bike.ts" import { defineTool } from "eve/tools"; import { z } from "zod"; import { garage } from "../lib/garage.js"; export default defineTool({ description: "Save a customer's bike so the shop remembers it across visits " + "(make, model, wheel size, and any standing notes).", inputSchema: z.object({ label: z.string().describe("A short name for the bike, e.g. 'the commuter'."), make: z.string(), model: z.string(), wheelSize: z.string().optional(), notes: z.string().optional(), }), async execute({ label, make, model, wheelSize, notes }) { garage.update((g) => ({ bikes: { ...g.bikes, [label]: { make, model, wheelSize, notes } }, })); return garage.get(); }, }); ``` ```ts title="agent/tools/recall_bikes.ts" import { defineTool } from "eve/tools"; import { z } from "zod"; import { garage } from "../lib/garage.js"; export default defineTool({ description: "Read the bikes the shop has on file for this customer.", inputSchema: z.object({}), async execute() { return garage.get(); }, }); ``` The dispatcher now has tools and memory. Next, it will choose a playbook based on *who's* standing at the counter. --- title: "A Playbook Per Tier" description: "Use defineDynamic and defineSkill to load a different shop playbook per membership tier, resolved from the caller's authenticated identity at session start." canonical_url: "https://vercel.com/academy/building-agents-with-eve/a-playbook-per-tier" md_url: "https://vercel.com/academy/building-agents-with-eve/a-playbook-per-tier.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-29T02:28:26.154Z" content_type: "lesson" course: "building-agents-with-eve" course_title: "Building Agents with eve" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # A Playbook Per Tier # A Playbook Per Tier A good front-desk advisor keeps the explanation simple for a first-time customer. With a shop regular who works on their own bikes, they can discuss torque specs and part numbers. The person behind the counter is the same; the playbook changes with the customer. We could try to cram all of that into `instructions.md`, but that gets messy: every caller would carry every tier's rules on every turn, and a walk-in would somehow know about the pro discount. What we want is a procedure that loads *only* for the caller it applies to, and only when it's relevant. A **skill** is a Markdown procedure the model loads on demand. Ours needs to be chosen per caller, so we'll make it a **dynamic** skill that resolves at the start of each session. ## Outcome The dispatcher loads a member or pro playbook based on the caller's tier, while a walk-in gets the plain desk, none the wiser about either. ## Hands-on exercise **Build the dynamic skill.** Create `agent/skills/shop-playbook.ts`. A dynamic capability is a resolver: it runs on a session event and returns a capability, or `null` for none. Ours runs on `session.started`, reads the caller's `tier`, and hands back the matching playbook as a skill: ```ts title="agent/skills/shop-playbook.ts" import { defineDynamic, defineSkill } from "eve/skills"; const PLAYBOOKS: Record = { pro: { title: "Pro / shop-mechanic playbook", markdown: "This caller is a pro mechanic. Talk torque specs and part numbers freely, " + "skip the absolute basics, and recommend a Full Overhaul when the symptoms " + "justify it. Reference /workspace/torque-specs.md for fastener values.", }, member: { title: "Member playbook", markdown: "This caller is a shop member. Mention the 10% labor discount on bookings, " + "and offer a free loaner bike whenever a job will keep their bike overnight.", }, }; export default defineDynamic({ events: { "session.started": async (_event, ctx) => { const tier = ctx.session.auth.current?.attributes.tier; const key = Array.isArray(tier) ? tier[0] : tier; const playbook = key ? PLAYBOOKS[key] : undefined; if (!playbook) return null; // no tier → no playbook, just the standard desk return defineSkill({ description: `Use when serving a ${key}-tier customer. ` + `Contains that tier's standing conventions.`, markdown: `# ${playbook.title}\n\n${playbook.markdown}`, }); }, }, }); ``` The pro playbook points at `/workspace/torque-specs.md`, a reference file the agent can open with its file tools when a pro asks for fastener values. Create it so there's something to read: ```md title="agent/sandbox/workspace/torque-specs.md" # Spoke & Mirror torque reference Fastener values in newton-meters (Nm). When in doubt, start at the low end and check. | Fastener | Torque (Nm) | | ------------------ | ----------- | | Disc rotor bolts | 6 | | Stem faceplate | 5 | | Seatpost clamp | 5 | | Crank arm (2-bolt) | 12 | | Cassette lockring | 40 | | Pedal into crank | 35 | ``` Anything under `agent/sandbox/workspace/` is seeded into the agent's sandbox at `/workspace/` when a session boots. Section 5 returns to the sandbox for deployment. **Give yourself a way to test it.** Here's the catch: the skill reads `tier` from the caller's *authenticated identity*, and right now nothing sets one. In the TUI you'll always get the plain desk. So add a small testing door in `agent/channels/eve.ts` that stamps a tier from a header: ```ts title="agent/channels/eve.ts" import { eveChannel } from "eve/channels/eve"; import { localDev, vercelOidc, type AuthFn } from "eve/channels/auth"; // TEMPORARY testing door: read a tier from a header so we can exercise the // per-tier playbook locally. We replace this with a real auth policy in 4.3. const demoTierAuth: AuthFn = async (request) => { const tier = request.headers.get("x-shop-tier"); if (!tier) return null; return { attributes: { tier }, principalType: "user", principalId: "demo-customer", authenticator: "demo", }; }; export default eveChannel({ auth: [demoTierAuth, vercelOidc(), localDev()], }); ``` \*\*Warning: Keep \`localDev()\` in the chain\*\* The eve channel fails closed: if no entry in the `auth` array admits a request, the route returns `Authorization is required for this route`. `localDev()` is the entry that admits the local TUI. If you merge `demoTierAuth` into your existing `eve.ts` by hand instead of replacing the file, don't drop `localDev()`, or the TUI will lock you out on the very next message. `demoTierAuth` only matches requests that carry an `x-shop-tier` header, which the TUI never sends. \*\*Warning: The tier comes from auth, not from the chat\*\* This is the whole point of keying on `ctx.session.auth`. A walk-in can't *talk* their way into the pro discount by saying "I'm a pro", the tier is a claim the door stamps, not text the model reads. The demo door fakes that claim from a header so you can test. In 4.3 you'll swap it for auth that earns the claim honestly. ## Try It In the plain TUI, no tier is set, so it's the standard desk: ```text you › my rear shifting is sloppy, what do you suggest? dispatcher › Sounds like the derailleur needs adjusting. A Basic Tune-Up ($65) covers that. Want me to check openings? ``` Now call over HTTP as a pro by sending the header. The first request returns an accepted session handle, so extract its ID and stream the answer: ```bash sid=$(curl -s -X POST http://127.0.0.1:2000/eve/v1/session \ -H 'content-type: application/json' \ -H 'x-shop-tier: pro' \ -d '{"message":"rear shifting is sloppy, what do you suggest?"}' \ | sed -E 's/.*"sessionId":"([^"]+)".*/\1/') curl -sN "http://127.0.0.1:2000/eve/v1/session/$sid/stream" ``` ```text dispatcher › Indexing's drifted, most likely. I'd check the B-tension and hanger alignment before anything else. If the cassette's worn past spec it's a Full Overhaul; torque the cassette lockring to spec (see the torque sheet). Want the overhaul booked? ``` The pro response includes torque specs, part-level detail, and an overhaul recommendation because the resolver loaded the `pro` playbook. Try `x-shop-tier: member` to hear about the labor discount and loaner bike instead. \*\*Note: Why eve info shows zero skills\*\* Run `eve info` and the skill count is `0`, as expected. A dynamic skill doesn't exist until a session resolves it. The file contains a resolver that may produce a skill depending on the caller. Plain desk no matter what header you send? Two checks: the resolver must read `ctx.session.auth.current?.attributes.tier` (not the message), and your `eve.ts` must list `demoTierAuth` in the `auth` array. If `eve info` reports a discovery error, make sure `shop-playbook.ts` default-exports the `defineDynamic(...)` result. ## Done-When - [ ] `agent/skills/shop-playbook.ts` default-exports `defineDynamic` resolving on `session.started`. - [ ] With no tier, the resolver returns `null` and the agent runs the plain desk. - [ ] Sending `x-shop-tier: pro` (or `member`) over HTTP produces the matching playbook's behavior. - [ ] You can explain why the tier must come from auth, not from the user's message. ## Solution The full `shop-playbook.ts` and the demo `eve.ts` are both shown above. The shape worth remembering: `defineDynamic` is a resolver keyed on a session event, and the same pattern also drives dynamic *tools* and *instructions*. Resolve on `session.started` for a per-session decision, then return a capability or `null`. One loose end remains: this `eve.ts` trusts a header anyone could send. That is useful for a local test and unsafe for production. Section 4 adds a web dashboard and Slack, then replaces this file with an auth policy that derives `tier` from a verified caller. --- title: "Book a Repair" description: "Add the book_repair tool so the dispatcher can commit a booking, then watch it cheerfully book a $180 overhaul with no human in the loop. The problem we fix next." canonical_url: "https://vercel.com/academy/building-agents-with-eve/book-a-repair" md_url: "https://vercel.com/academy/building-agents-with-eve/book-a-repair.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-29T02:28:26.184Z" content_type: "lesson" course: "building-agents-with-eve" course_title: "Building Agents with eve" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Book a Repair # Book a Repair So far, the dispatcher has only read data. Now we'll give it a tool that changes the calendar and commits the shop to a repair. We'll start without an approval check so you can see the risk directly. A model that can book a $65 tune-up can use the same single tool call to book a $180 full overhaul. Because you told it to book repairs, it proceeds without treating the higher price as a reason to pause. This lesson builds the unguarded tool. The next lesson adds the approval check. ## Outcome The dispatcher books a repair into a real slot, including an expensive one, with no human checkpoint. By design, for now. ## Hands-on exercise Create `agent/tools/book_repair.ts`. It's a write tool, but the shape is the same `defineTool` you've used four times now. The catalog helpers in `agent/lib/shop.ts` do the heavy lifting: - `quoteCents(serviceIds)` totals the price of the booked services. - `bookSlot(slotId, summary)` marks the slot taken and records what it's for. - `getService` and `formatUsd` turn ids into names and cents into dollars. The input includes `serviceIds` (a non-empty array from `lookup_service`), a `slotId` from `check_availability`, and an optional `bikeLabel`. In `execute`, resolve the service names, total the quote, commit the slot, and return a confirmation. Leave out the price check and confirmation step for now. We're building the naive version first on purpose. \*\*Note: Why build the unsafe version at all\*\* It would be easy to jump straight to the guarded tool, which would make the risk feel hypothetical. Building it unguarded for one lesson lets you watch the problem happen before you fix it with the approval gate in 3.2. ## Try It Restart and push it toward the most expensive thing on the menu: ```bash npx eve dev ``` ```text you › my whole drivetrain is shot and the headset's notchy. just do everything, book me for Tuesday. ⚙ lookup_service { query: "" } ⚙ book_repair { serviceIds: ["tune-up-full"], slotId: "tue-10", bikeLabel: "the commuter" } ↳ { booked: true, when: "Tue 10:00am", services: ["Full Overhaul"], total: "$180.00" } dispatcher › Done! I've booked the commuter in for a Full Overhaul on Tuesday at 10:00am. That's $180.00. See you then! ``` \*\*Note: Your dispatcher may diagnose first, push past it\*\* The diagnose-first persona from 1.1 may ask clarifying questions before booking. That is good front-desk behavior, although it can obscure the issue we're testing. To force the booking through in one move, name the service and slot directly: *"Skip the diagnosis, book the Full Overhaul for Tuesday at 10am."* Once the agent decides to book, nothing in the tool makes it pause for an expensive job. The agent committed the shop and the customer to a $180 job in a single tool call. It never asked for confirmation or paused before booking. The same path would have handled a $20 flat repair. \*\*Note: This is the bug, and it's working as written\*\* `book_repair` did exactly what you wrote: it accepted input, committed the slot, and reported back. An unguarded write tool can work as implemented while taking an action nobody approved. An agent that can act needs a rule for when to wait. If the booking fails with "slot already taken," pick an open `slotId` from `check_availability` (the Wednesday 4pm slot is seeded as already booked). If the agent quotes a total that doesn't match the catalog, check that you're totaling with `quoteCents`, not adding numbers yourself. ## Done-When - [ ] `agent/tools/book_repair.ts` commits a booking via `bookSlot` and returns the confirmation. - [ ] Booking a cheap service works end to end. - [ ] Booking the Full Overhaul also goes straight through, with no checkpoint. - [ ] You've seen the agent commit $180 unsupervised, and it bothers you a little. ## Solution ```ts title="agent/tools/book_repair.ts" import { defineTool } from "eve/tools"; import { z } from "zod"; import { getService, quoteCents, bookSlot, formatUsd } from "../lib/shop.js"; export default defineTool({ description: "Book one or more services into an open slot for a customer's bike. " + "Returns the confirmation and the total quote.", inputSchema: z.object({ serviceIds: z .array(z.string()) .min(1) .describe("Service ids from lookup_service."), slotId: z.string().describe("An open slot id from check_availability."), bikeLabel: z.string().optional().describe("Which of the customer's bikes this is for."), }), async execute({ serviceIds, slotId, bikeLabel }) { const names = serviceIds.map((id) => getService(id)?.name ?? id); const total = quoteCents(serviceIds); const summary = `${names.join(" + ")}${bikeLabel ? ` (${bikeLabel})` : ""}`; const slot = bookSlot(slotId, summary); return { booked: true, when: slot.label, services: names, total: formatUsd(total), }; }, }); ``` The tool is useful, but an expensive booking still goes through without review. In the next lesson, one `approval` field will pause those bookings while leaving cheap ones unchanged. --- title: "Pause for a Sign-off" description: "Add an approval gate to book_repair so expensive bookings park for a human yes, then resume from the exact step. The durable pause/resume you saw streaming in 1.3." canonical_url: "https://vercel.com/academy/building-agents-with-eve/pause-for-a-signoff" md_url: "https://vercel.com/academy/building-agents-with-eve/pause-for-a-signoff.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-29T02:28:26.197Z" content_type: "lesson" course: "building-agents-with-eve" course_title: "Building Agents with eve" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Pause for a Sign-off # Pause for a Sign-off In the last lesson, the dispatcher booked a $180 overhaul without so much as a raised eyebrow. We need `book_repair` to stop when the stakes are high enough and ask a person first. Cheap jobs can continue immediately; expensive ones should wait for a yes. Eve adds human approval through one field on the tool. Its durable session machinery handles the queueing, waiting, and resuming from the exact step where the run stopped. You saw that machinery stream as `session.waiting` back in 1.3. Here, you'll define when the tool should use it. ## Outcome `book_repair` runs cheap bookings straight through but parks expensive ones on a human approval, then resumes the booking from the exact step once you say yes. ## Hands-on exercise Open the `book_repair` you wrote in 3.1. You're adding exactly one field, `approval`, and a threshold constant. Everything else stays. `approval` decides, before `execute` runs, whether this particular call needs a human first. It can be a blanket helper from `eve/tools/approval`, `always()`, `once()`, `never()`, or, when the decision depends on the *input*, your own predicate. Booking is the second case: a $20 flat repair shouldn't interrupt anyone, but a $180 overhaul should. So we gate on the quote: ```ts approval: ({ toolInput }) => quoteCents(toolInput?.serviceIds ?? []) > APPROVAL_THRESHOLD_CENTS, ``` The predicate sees the same input `execute` will, so we total the quote the same way `execute` does and compare it to the threshold. Bookings under the line proceed immediately. Over it, eve pauses the turn and surfaces an approval request before `execute` runs. \*\*Note: Helper or predicate?\*\* Reach for `always()`/`once()`/`never()` when the answer is the same every time (a `delete_everything` tool is `always()`). Reach for a predicate when it depends on the arguments, like our cost gate. Same `approval` field, two shapes. With that field in place, eve turns a `true` result from `approval` into a parked turn and a structured approval request. The channel renders the request as buttons in the TUI, Block Kit in Slack, or a control in your web app. ## Try It Restart the dev server. That resets the session, so any bike you saved back in 2.2 is gone (`defineState` lives and dies with the session). Name the bike in the booking itself so the dispatcher has what it needs. Start with a booking under the line; it behaves exactly like before: ```text you › I've got a rear flat on my Surly Disc Trucker, 700c. Book a flat repair for Tuesday morning. ⚙ book_repair { serviceIds: ["flat-fix"], slotId: "tue-10", bikeLabel: "Surly Disc Trucker" } ($20, under $150) dispatcher › Booked! Flat Repair on your Surly Disc Trucker, Tuesday at 10:00am. $20.00. ``` \*\*Note: A spoken “shall I confirm?” is not the approval gate\*\* Your dispatcher may still ask "shall I go ahead and book?" before the cheap job. That question comes from the persona; eve has not stopped the turn. Check for the `⏸ approval required … [approve] [deny]` control. Under the threshold, `approval` returns `false`, so `execute` runs as soon as the model calls the tool. The overhaul will trigger the structured pause that blocks `execute` until a person approves. Now push it over the line and watch it stop. Pick a different slot, the Tuesday-morning one is taken now: ```text you › actually, do the full overhaul on the Surly too, Tuesday afternoon. ⚙ book_repair { serviceIds: ["tune-up-full"], slotId: "tue-14", bikeLabel: "Surly Disc Trucker" } ⏸ approval required: Full Overhaul, $180.00. Approve this booking? [approve] [deny] ``` The turn is parked. Nothing was booked yet, `approval` runs *before* `execute`, so the slot is untouched while it waits. Approve it: ```text you › approve ↳ { booked: true, when: "Tue 2:00pm", services: ["Full Overhaul"], total: "$180.00" } dispatcher › Approved and booked, Full Overhaul on the Surly, Tuesday at 2:00pm. $180. ``` The booking resumed from the exact step it paused on, ran `execute`, and committed. If you'd denied it, `execute` never runs and the model is told the booking was declined. \*\*Note: This is the durable pause from 1.3, doing real work\*\* Remember `session.waiting` in the event stream? The parked turn is durably suspended without holding a process open. The approval can come a minute later or, on a deployed agent, an hour later from a manager's phone. When it arrives, the turn picks up precisely where it stopped. Cheap bookings prompting for approval, or expensive ones sailing through? Your predicate is probably totaling the wrong thing, confirm it calls `quoteCents(toolInput.serviceIds)` and compares against the same threshold in cents. And make sure `approval` sits on the tool definition object, beside `inputSchema`, not inside `execute`. ## Done-When - [ ] `book_repair` has an `approval` predicate keyed on the booking's total cost. - [ ] A booking under the threshold runs with no approval gate (no `⏸ approve/deny` pause, even if the dispatcher confirms conversationally). - [ ] A booking over the threshold parks on an approval request and books nothing until answered. - [ ] Approving resumes and commits; denying skips `execute`. ## Solution ```ts title="agent/tools/book_repair.ts" {5,19-20} import { defineTool } from "eve/tools"; import { z } from "zod"; import { getService, quoteCents, bookSlot, formatUsd } from "../lib/shop.js"; const APPROVAL_THRESHOLD_CENTS = 15000; // anything over $150 needs a human yes export default defineTool({ description: "Book one or more services into an open slot for a customer's bike. " + "Returns the confirmation and the total quote.", inputSchema: z.object({ serviceIds: z.array(z.string()).min(1).describe("Service ids from lookup_service."), slotId: z.string().describe("An open slot id from check_availability."), bikeLabel: z.string().optional().describe("Which of the customer's bikes this is for."), }), // Cost-based gate: cheap jobs run straight through, big-ticket bookings park // on an approval request. approval runs before execute and sees the tool // input, so we re-derive the quote here the same way execute will. approval: ({ toolInput }) => quoteCents(toolInput?.serviceIds ?? []) > APPROVAL_THRESHOLD_CENTS, async execute({ serviceIds, slotId, bikeLabel }) { const names = serviceIds.map((id) => getService(id)?.name ?? id); const total = quoteCents(serviceIds); const summary = `${names.join(" + ")}${bikeLabel ? ` (${bikeLabel})` : ""}`; const slot = bookSlot(slotId, summary); return { booked: true, when: slot.label, services: names, total: formatUsd(total) }; }, }); ``` The threshold and `approval` predicate put the risk and its guardrail in the same file. eve's durable runtime keeps the conversation paused until a person answers. Section 4 will put this guarded dispatcher in front of users. --- title: "A Web Dashboard" description: "Run the dispatcher behind a browser dashboard. Understand how withEve mounts the agent same-origin and how useEveAgent drives it, then theme it for the shop." canonical_url: "https://vercel.com/academy/building-agents-with-eve/a-web-dashboard" md_url: "https://vercel.com/academy/building-agents-with-eve/a-web-dashboard.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-29T02:28:26.221Z" content_type: "lesson" course: "building-agents-with-eve" course_title: "Building Agents with eve" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # A Web Dashboard # A Web Dashboard You've talked to the dispatcher in a terminal and over `curl`. Neither is something you'd hand a customer. The front door most people expect is a web page: a text box, a conversation, a send button. The browser chat uses the same HTTP session API you drove by hand in 1.3. The generated web channel gives those routes a customer-facing interface while leaving the dispatcher unchanged. Two pieces handle the integration: `withEve` wires the agent's routes into a Next.js app, and `useEveAgent` drives them from React. In this lesson, you'll inspect the generated chat UI and give it the shop's identity. The next two lessons add Slack and real authentication. ## Outcome The dispatcher answers in a browser dashboard, approvals included, and the page wears the Spoke & Mirror name instead of the scaffold's default. ## Hands-on exercise **Open the web channel.** You scaffolded this front end in 1.1 with `--channel-web-nextjs` and deliberately left it alone while building the agent. It includes a `next.config.ts` that mounts the routes and an `app/` directory with the React chat. Run `git status` and inspect those files now. File paths can shift between eve versions, so use your generated tree as the source of truth. **How the door is wired.** The generated `next.config.ts` wraps your config with `withEve`: ```ts title="next.config.ts" import type { NextConfig } from "next"; import { withEve } from "eve/next"; const nextConfig: NextConfig = {}; export default withEve(nextConfig); ``` `withEve` mounts the agent's `/eve/v1/*` routes on the Next.js app's origin. The browser can reach the agent without crossing a CORS boundary or reading an environment variable. In development, `npm run dev` starts the eve runtime alongside `next dev` and forwards those routes. \*\*Note: Switch from the TUI to the dashboard\*\* Until now you ran `npm run dev:eve` for the terminal UI. `npm run dev` runs `next dev` and serves the dashboard at `localhost:3000`. Both scripts have been in `package.json` since 1.1; this is the first time we use the web one. **Now, how the browser drives it.** Open the generated chat component. It leans on one hook: ```tsx const agent = useEveAgent(); ``` That single call opens a durable session, sends turns, streams replies, and hands you render-ready state. The component reads from it: - `agent.data.messages`, the conversation (an `EveMessage[]` following the AI SDK `UIMessage` convention), mapped to message bubbles. - `agent.status`, `"ready" | "submitted" | "streaming" | "error"`, used to disable the composer while the agent is working. - `agent.send(message)`, to send a text turn (or pass `UserContent` parts for richer input). - `agent.respond(inputResponses)`, to answer a parked approval or other input request. - `agent.cancel()`, to cancel the active turn. The approval gate you built in 3.2 shows up here automatically. When `book_repair` parks on a big booking, the message renders approve/deny controls, and clicking one sends your answer back through the hook. You wrote zero approval-UI code; `useEveAgent` surfaces the parked request and the generated component renders it. **Make it the shop's.** The generated app names the agent after your project. Give it the shop's identity instead. Open the generated chat component, `app/_components/agent-chat.tsx` in this eve version, and rename the agent (the constant the scaffold uses for the display name): ```tsx const AGENT_NAME = "Spoke & Mirror"; ``` Then set the page metadata in the app's `layout.tsx`: ```tsx export const metadata: Metadata = { title: "Spoke & Mirror Dispatcher", description: "Book bike repairs at Spoke & Mirror Cyclery.", }; ``` \*\*Note: Match your generated files\*\* The exact paths and display-name constant come from the pinned 0.41.0 scaffold. If your component names the agent differently, rename whatever it actually uses. A grep for your project name points you at both spots. ## Try It Boot the dashboard: ```bash npm run dev ``` Open `http://localhost:3000`. You'll see the Spoke & Mirror chat: a header with the shop's name and a message box at the bottom. Click into the box, type a real customer question, and press Enter: ```text my rear brake feels spongy on the way down the hill ``` The reply streams into the page in the front-desk voice you wrote in 1.1. The agent is now answering in a browser without changes to any tool. Now run it through the rest of its paces: 1. Ask "what's a brake bleed cost?" The `lookup_service` tool runs and returns the same catalog price as the TUI. 2. Ask it to book a flat repair. The booking goes straight through. 3. Ask for the Full Overhaul. The approval gate from 3.2 appears as **approve / deny buttons** in the chat. Click approve to resume the turn and complete the booking. Only the surface changed. The dispatcher, tools, and approval logic stayed in place. \*\*Note: The approval crossed surfaces unchanged\*\* You wrote `approval` once in the tool. The TUI rendered it as a text prompt, while the browser used buttons. Each channel can render the same tool behavior for its platform. Blank page or a connection error in the console? Make sure you ran `npm run dev`; `npm run dev:eve` opens the TUI and does not serve the web app. If the chat loads but every message errors, the agent could not reach a model. Use the credential checks from 1.1: `eve link` or `AI_GATEWAY_API_KEY`. ## Done-When - [ ] `npm run dev` serves the dashboard at `localhost:3000`. - [ ] The dispatcher answers in the browser and calls tools (you see real prices/slots). - [ ] An expensive booking renders approve/deny controls, and approving completes it. - [ ] The page and chat header show "Spoke & Mirror", not the scaffold default. ## Solution The web scaffold from 1.1 did the wiring; `withEve` and `useEveAgent` came with it. Your only edits are the shop's identity, the display name in the chat component: ```tsx const AGENT_NAME = "Spoke & Mirror"; ``` and the page metadata in `layout.tsx`: ```tsx export const metadata: Metadata = { title: "Spoke & Mirror Dispatcher", description: "Book bike repairs at Spoke & Mirror Cyclery.", }; ``` The generated channel gives you a working Next.js interface. The dispatcher's tools, state, skill, and approval logic remain unchanged. Next, you'll expose it through Slack. --- title: "Add Slack" description: "Put the dispatcher in Slack with slackChannel and Vercel Connect, no SLACK_BOT_TOKEN, no tool changes. One more front door on the same agent." canonical_url: "https://vercel.com/academy/building-agents-with-eve/add-slack" md_url: "https://vercel.com/academy/building-agents-with-eve/add-slack.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-29T02:28:26.233Z" content_type: "lesson" course: "building-agents-with-eve" course_title: "Building Agents with eve" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Add Slack # Add Slack The shop's mechanics live in Slack all day. Making them open a browser tab to ask the dispatcher anything seems rude. So let's meet them where they already are. Adding Slack should leave the tools, skill, state, and approval logic untouched. A channel normalizes incoming messages, tracks how to resume the conversation, and sends replies. One file in `channels/` makes Slack another client of the dispatcher. Credentials are the new concern here. Vercel Connect manages them, so your code never handles a `SLACK_BOT_TOKEN`. ## Outcome The dispatcher answers `@mentions` in Slack, in threads, with no changes to any tool, skill, or state. ## Hands-on exercise **Add the Slack channel.** Slack delivers events to a public URL and needs a verified bot token to reply. Vercel Connect brokers both, so there's no signing secret or bot token in your code. eve 0.41.0 guides the whole setup: ```bash npx eve add channel/slack ``` The command links or creates a Vercel project, creates or reuses a Slack connector, opens Slack authorization in your browser, registers `/eve/v1/slack` as the trigger destination, installs `@vercel/connect`, and writes `agent/channels/slack.ts`. Follow the prompts and finish the workspace authorization in the browser. \*\*Warning: Use the current command shape\*\* The old `eve channels add slack` command was removed. The registry form is `eve add channel/slack`. Let the guided setup reuse an existing connector when it finds one; repeatedly creating connectors can leave duplicate Slack apps in the workspace. \*\*Warning: Re-running \`create\` installs a new Slack app each time\*\* Every `vercel connect create slack` installs a *fresh* Slack app into your workspace, and `vercel connect remove` deletes the connector on Vercel's side but does **not** uninstall that app from Slack. So if you recreate the connector a few times while debugging, you'll end up with several identical bots in the `@`-mention list. Before re-creating, uninstall the stale ones in Slack under **Manage apps** (`https://app.slack.com/manage`), so you're only ever mentioning the live one. \*\*Note: Connect is a beta on all plans\*\* `vercel connect` ships with the Vercel CLI, no feature flag needed. If your CLI reports `connect` as an unknown subcommand, update it (`npm i -g vercel@latest`) and retry; the command surface is documented under [vercel connect](https://vercel.com/docs/cli/connect). **Inspect and extend the channel.** The generated `agent/channels/slack.ts` defines where credentials come from, when to dispatch a turn, and how to deliver the reply. Replace it with the version in the solution below to make the dispatch and final-thread delivery explicit. - **Credentials:** `connectSlackCredentials(process.env.SLACK_CONNECTOR ?? "slack/spoke-and-mirror")` returns the bot token and webhook verifier, both managed by Connect. Reading the uid from `SLACK_CONNECTOR` keeps it out of code; the fallback matches the connector you just named, so it works without setting the variable. - **Dispatch:** `onAppMention` decides whether a mention becomes a turn. Use `defaultSlackAuth` to stamp trusted Slack identity and ignore bot chatter. - **Delivery:** on `message.completed`, post the final reply to the thread, skipping interim tool-call narration. \*\*Note: The thread maps to the session\*\* You don't manage Slack threading by hand. The channel maps a thread to a durable session, so a follow-up mention in the same thread resumes the conversation, just as posting to the session ID did in 1.3. \*\*Note: Slack identity is not a shop membership tier\*\* `defaultSlackAuth` stamps Slack attributes such as user, team, channel, and thread IDs. It does not know the customer's Spoke & Mirror membership, so the dynamic playbook uses the plain desk on Slack. A production extension can map the trusted Slack user ID to a server-side customer record and add `attributes.tier`; never derive the tier from message text. Because Slack delivers over the public internet, you can't exercise this one on `localhost`. You'll deploy to get a URL. We cover deployment properly in Section 5; for now, ship it with `npx eve deploy`, which wraps `vercel deploy --prod`, installs dependencies, and pulls your environment: ```bash npx eve deploy ``` ## Try It In a Slack workspace where the app is installed, mention the bot in a channel: ```text @dispatcher my commuter's front brake is rubbing, what's that cost to fix? ``` The bot replies in a thread, runs `lookup_service`, and quotes the catalog price. A reply in the thread continues the session. The approval gate also carries over: ask it to book the Full Overhaul and Slack renders the approve/deny prompt as buttons. Bot shows up in Slack but never replies? Re-run `npx eve add channel/slack`; it inspects the current project, connector, Slack installation, and trigger destination without blindly creating another app. Confirm the destination is `/eve/v1/slack`. By default, the channel gives the model the triggering mention rather than the earlier thread backlog. Opt into thread context if you need that history. ## Done-When - [ ] A Connect Slack client is attached with trigger path `/eve/v1/slack`. - [ ] `agent/channels/slack.ts` exports `slackChannel` with `connectSlackCredentials`. - [ ] `@mentioning` the bot returns a real, tool-backed answer in a thread. - [ ] An expensive booking renders approve/deny as Slack buttons. ## Solution ```ts title="agent/channels/slack.ts" import { connectSlackCredentials } from "@vercel/connect/eve"; import { defaultSlackAuth, slackChannel } from "eve/channels/slack"; export default slackChannel({ // The connector uid lives in SLACK_CONNECTOR (set it on the project, or leave // it unset). The fallback matches the connector you named with `vercel connect // create slack --name spoke-and-mirror`, so this works out of the box. credentials: connectSlackCredentials( process.env.SLACK_CONNECTOR ?? "slack/spoke-and-mirror", ), // Answer @mentions from a real user; ignore bot chatter. defaultSlackAuth // stamps Slack identity, but does not invent a shop membership tier. onAppMention: (ctx, message) => message.author ? { auth: defaultSlackAuth(message, ctx) } : null, events: { // Post the final reply to the thread, skipping interim tool-call narration. // Event handlers receive (eventData, channel, ctx); Slack handles live on `channel`. "message.completed"(eventData, channel, ctx) { if (eventData.finishReason === "tool-calls") return; if (eventData.message) channel.thread.post(eventData.message); }, }, }); ``` The same agent now has web and Slack entrypoints. Before shipping, we'll replace the test identity with authenticated customer data. --- title: "Stamp Identity at the Door" description: "Replace the throwaway header door from 2.3 with a real ordered auth walk that derives the caller's identity, and tier, from a session, then feeds the per-tier playbook." canonical_url: "https://vercel.com/academy/building-agents-with-eve/stamp-identity" md_url: "https://vercel.com/academy/building-agents-with-eve/stamp-identity.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-29T02:28:26.246Z" content_type: "lesson" course: "building-agents-with-eve" course_title: "Building Agents with eve" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Stamp Identity at the Door # Stamp Identity at the Door In 2.3, we trusted an `x-shop-tier` header to test the per-tier playbook. Anyone who sent `x-shop-tier: pro` received the pro discount. Now we'll make the Eve HTTP/Web entrypoint derive the tier from an authenticated customer instead. Slack already has trusted Slack identity, but mapping that identity to a shop membership is a separate integration. The job of a door is to answer one question before any work happens: *who is this?* In eve that's **route auth**, a policy on the channel that runs before the model does a thing. It decides who's allowed in, and it produces the caller's identity, the same `ctx.session.auth` the playbook reads. Auth helps us know our customer. ## Outcome The dispatcher derives the caller's identity from a real session, stamps their `tier` from the shop's records, and feeds it to the per-tier playbook, with no way for a caller to set their own tier. ## Hands-on exercise The `auth` option on the channel takes a list of functions eve walks **in order**. Each one does one of three things: - returns a `SessionAuthContext` → accept the caller, stop the walk - returns `null` → "not my caller," fall through to the next entry - throws → reject with a specific status If every entry falls through, the request gets a `401`. That ordered walk is the whole model: put your real auth first, then the framework's dev and OIDC helpers as catch-alls. You need something that turns a request into a known customer. In a real app that's your auth provider (Clerk, Auth.js, your own OIDC). For the course, drop in a small stand-in: ```bash curl -f --create-dirs -o agent/lib/auth.ts https://raw.githubusercontent.com/vercel-labs/bike-shop-agent/main/agent/lib/auth.ts ``` It exposes one function, `getCustomer(request)`, that reads a session cookie and returns the customer on file, *including their tier*: ```ts title="agent/lib/auth.ts (excerpt)" export interface Customer { readonly id: string; readonly tier?: "member" | "pro"; } // Reads the `shop_session` cookie and looks the customer up server-side. export function getCustomer(request: Request): Customer | null; ``` The tier comes from the customer record looked up on the server, rather than a value supplied by the request. This closes the hole from 2.3. Rewrite `agent/channels/eve.ts` so `appAuth` uses it: - Call `getCustomer(request)`. No customer? Return `null` and fall through. - Otherwise return a `SessionAuthContext`: the customer's `id` as `principalId`, `principalType: "user"`, an `authenticator` and `issuer` label, and `attributes.tier` *only when the record has one*. \*\*Note: Identity is set once, and flows everywhere\*\* This file stamps the caller's identity on `ctx.session.auth.current` for the session. The playbook from 2.3 reads `attributes.tier`, and a tool could use `principalId` to scope data. The runtime carries identity without passing it through each function. ## Try It Restart the server. Testing a tier means calling as a specific customer, which means sending the `shop_session` cookie, so we use `curl` here rather than the dev TUI (the TUI always runs as a local dev caller with no cookie, the plain desk). One thing about the API first: a `POST` to `/eve/v1/session` *starts* the turn and returns an accepted session handle (`{ ok, sessionId, status: "accepted" }`), not the reply. The dispatcher's answer streams back from `/eve/v1/session//stream`. So each test is two calls, POST to start, then attach to the stream to read the answer. This shell helper does both, and filters the stream down to the words that reveal which playbook loaded: ```bash ask() { # usage: ask "" sid=$(curl -s -X POST http://127.0.0.1:2000/eve/v1/session \ -H 'content-type: application/json' \ -H "cookie: shop_session=$1" \ -d "{\"message\":\"$2\"}" \ | sed -E 's/.*"sessionId":"([^"]+)".*/\1/') curl -sN -H "cookie: shop_session=$1" \ "http://127.0.0.1:2000/eve/v1/session/$sid/stream" \ | grep -iE "torque|overhaul|discount|loaner|tune-up" } ``` Call as a pro: ```bash ask demo-pro "rear shifting is sloppy, what do you suggest?" ``` The reply talks **torque** specs and recommends the **overhaul**, the pro playbook, because `getCustomer` returned `{ id: "cust_003", tier: "pro" }` and `appAuth` stamped `tier: "pro"`. Swap the cookie and the desk changes: - `ask demo-member "rear shifting is sloppy, what do you suggest?"` → the 10% labor **discount** and a free **loaner**. - `ask demo-walk-in "rear shifting is sloppy, what do you suggest?"` → a basic **tune-up** with no tier perks. (Drop the `| grep …` line to read the whole reply; the filter is only there to make the difference jump out of the stream.) Now try to cheat the 2.3 way, send the tier as a header with no session cookie: ```bash sid=$(curl -s -X POST http://127.0.0.1:2000/eve/v1/session \ -H 'content-type: application/json' \ -H 'x-shop-tier: pro' \ -d '{"message":"rear shifting is sloppy, what do you suggest?"}' \ | sed -E 's/.*"sessionId":"([^"]+)".*/\1/') curl -sN "http://127.0.0.1:2000/eve/v1/session/$sid/stream" \ | grep -iE "torque|overhaul|discount|loaner|tune-up" ``` You get the **plain desk**, the same `tune-up` as `demo-walk-in`. The header is ignored: with no `shop_session` cookie, `appAuth` finds no customer and falls through, so the tier never gets stamped. A walk-in can't talk, or header, their way into the pro discount anymore. \*\*Note: What localDev and vercelOidc are doing in the list\*\* `appAuth` handles your real customers, `vercelOidc()` preserves authenticated Vercel identity, and `localDev()` admits the synthetic principal only inside an `eve dev` or `vercel dev` process. Order matters: real auth first, development fallback last. Anything none of them recognizes gets a `401`, auth fails closed. Getting a `401` for a customer that should work? Confirm `getCustomer` is reading the `shop_session` cookie and that `appAuth` is *first* in the `auth` array. If the tier doesn't take effect, check that you stamp `attributes.tier` from `customer.tier`. The playbook reads that exact path. ## Done-When - [ ] `agent/channels/eve.ts` runs an ordered walk `[appAuth, vercelOidc(), localDev()]`. - [ ] `appAuth` stamps `tier` from the customer record, not from a request header. - [ ] A `shop_session=demo-pro` cookie produces the pro playbook; `x-shop-tier: pro` does nothing. - [ ] An unrecognized caller falls through to the catch-alls (or `401` in production). ## Solution ```ts title="agent/channels/eve.ts" import { eveChannel } from "eve/channels/eve"; import { localDev, vercelOidc, type AuthFn } from "eve/channels/auth"; import { getCustomer } from "../lib/auth.js"; const appAuth: AuthFn = async (request) => { const customer = getCustomer(request); if (!customer) return null; // not one of our customers → fall through // The tier comes from the customer's record, not from the request. The // per-tier playbook (agent/skills/shop-playbook.ts) reads it from here. const attributes: Record = {}; if (customer.tier) attributes.tier = customer.tier; return { principalId: customer.id, principalType: "user", authenticator: "app", issuer: "spoke-and-mirror", attributes, }; }; export default eveChannel({ auth: [appAuth, vercelOidc(), localDev()], }); ``` The per-tier playbook now runs from authenticated customer data instead of a test header. Section 5 confirms that production rejects callers the auth policy does not recognize. --- title: "Lock the Doors" description: "Make the dispatcher production-safe: understand the fail-closed auth guarantee, keep the model credential and secrets in env, and confirm an unknown caller gets shut out." canonical_url: "https://vercel.com/academy/building-agents-with-eve/lock-the-doors" md_url: "https://vercel.com/academy/building-agents-with-eve/lock-the-doors.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-29T02:28:26.270Z" content_type: "lesson" course: "building-agents-with-eve" course_title: "Building Agents with eve" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Lock the Doors # Lock the Doors The dispatcher is about to move from your laptop to the public internet. Before deploying, we'll confirm that only recognized callers can reach it. The auth walk from 4.3 already decides who gets in. This lesson checks what happens to unrecognized callers and confirms that credentials stay out of source code. ## Outcome You can state exactly what an unauthenticated request to the deployed dispatcher gets, and your model credential and any secrets live in environment variables, not in the repo. ## Hands-on exercise **Understand fail-closed.** Your `auth` list is `[appAuth, vercelOidc(), localDev()]`. Walk through what each does to a *public production* request from a stranger's browser: - `appAuth` calls `getCustomer` and finds no valid session → returns `null`, falls through. - `vercelOidc()` wants a valid Vercel OIDC token the stranger doesn't have → falls through. - `localDev()` only grants its synthetic principal inside an `eve dev` or `vercel dev` process. Production isn't a dev process → falls through. Every entry falls through, so eve returns a `401`. Routes reject any request that no authenticator explicitly accepts. Unrecognized traffic is denied by default. \*\*Warning: Why localDev() is safe to leave in production\*\* `localDev()` keys off the running process, not the request hostname. `eve dev` sets the local-development context; `eve start` and deployed production do not. A forged `Host` header cannot turn production into local development. Keep `vercelOidc()` before `localDev()` so local requests carrying real Vercel identity are not replaced by the synthetic dev principal. \*\*Note: The scaffold ships placeholderAuth() for this reason\*\* A fresh eve project starts with `placeholderAuth()` in the walk. In production, it returns a clear "auth isn't configured yet" `401` so a half-built app fails closed. You replaced it with real auth in 4.3. **Keep secrets in env.** The dispatcher needs exactly one credential to run: a model key. The easy, secret-free path is the Vercel AI Gateway, link a Vercel project and a gateway id like `anthropic/claude-opus-4.8` authenticates through OIDC, with no provider key to store anywhere: ```bash npx eve link # links this directory to a Vercel project and pulls Gateway creds ``` Anything sensitive your real `getCustomer` would use (a session-signing secret, a JWT key) belongs in Vercel's environment variables, never in source. Route-auth secrets are re-materialized from env at boot and never baked into the build artifacts. ## Try It Confirm the production policy before you deploy: `appAuth` is first, authenticated Vercel identity comes next, and the process-only development fallback is last. Do not try to prove this by changing the `Host` header under `eve dev`: that process deliberately enables `localDev()`, regardless of hostname. You'll exercise the real `401` and authenticated `202` against the deployment in 5.2. Now sweep for secrets you don't want in git: ```bash git grep -nEi '(sk-[a-z0-9]{16,}|(api[_-]?key|secret)[[:space:]]*=[[:space:]]*[^[:space:]]{8,})' \ -- . ':!package-lock.json' || echo "clean" ``` \*\*Note: Production-safe is mostly verifying, not adding\*\* Notice you barely wrote code here. eve's defaults, fail-closed routes, secrets re-materialized from env, OIDC model auth, do the heavy lifting. "Hardening for production" is largely confirming the framework's safe defaults are still in force and that you didn't paste a key somewhere. If the sweep finds a real value, remove it from source, rotate it, and put the replacement in Vercel environment variables. If you can't reach a model after linking, run `eve link` again and confirm the project has AI Gateway access. ## Done-When - [ ] You can explain why an unknown production caller falls through every authenticator to `401`. - [ ] The auth order is `[appAuth, vercelOidc(), localDev()]`. - [ ] The model credential comes from a linked Vercel project / env, not source. - [ ] `git grep` for secrets comes back clean. ## Solution There's no new file here. The solution is the auth walk you wrote in 4.3, now verified as the production lock: ```ts title="agent/channels/eve.ts (unchanged from 4.3)" export default eveChannel({ auth: [appAuth, vercelOidc(), localDev()], }); ``` `appAuth` admits customers. `vercelOidc()` covers authenticated Vercel traffic, and `localDev()` only activates in the dev process. Everything else receives the fail-closed `401`. With credentials in the environment, the dispatcher is ready to deploy. --- title: "Deploy to Vercel" description: "Build and deploy the dispatcher to Vercel, set the sandbox backend, smoke-test the live routes with curl and eve dev, and find your runs in the dashboard." canonical_url: "https://vercel.com/academy/building-agents-with-eve/deploy-agent-to-vercel" md_url: "https://vercel.com/academy/building-agents-with-eve/deploy-agent-to-vercel.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-29T02:28:26.283Z" content_type: "lesson" course: "building-agents-with-eve" course_title: "Building Agents with eve" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Deploy to Vercel # Deploy to Vercel The agent runs the same way locally and on Vercel. Build it, deploy it, and test the live URL to confirm the dispatcher answers from the hosted environment. The one production-specific choice is where the sandbox runs, and even that's mostly handled for you. Let's ship it. ## Outcome The Spoke & Mirror dispatcher runs on Vercel, and you've confirmed it live by hitting its deployed routes. ## Hands-on exercise **Pick the sandbox backend.** Your agent has a sandbox (it seeds `torque-specs.md` into `/workspace`). Locally that runs on your machine; on Vercel it runs on hosted Vercel Sandbox. One definition covers both, `defaultBackend()` resolves to the right one per environment: ```ts title="agent/sandbox/sandbox.ts" import { defineSandbox, defaultBackend } from "eve/sandbox"; export default defineSandbox({ backend: defaultBackend(), }); ``` \*\*Note: Pinning a backend explicitly\*\* `defaultBackend()` is the portable choice: it picks the best available environment (Vercel Sandbox when hosted on Vercel, then Docker, microsandbox, or just-bash). To pin Vercel unconditionally instead, import its factory from the nested path and use it directly: ```ts import { vercel } from "eve/sandbox/vercel"; // backend: vercel({ runtime: "node24" }) ``` eve ships a pinned factory per backend; check `node_modules/eve/docs/sandbox.mdx` for the set in your installed version. **Build and deploy.** `eve deploy` runs the supported production workflow: it links the project when needed, installs dependencies, builds the app, and deploys it to Vercel. From the project root: ```bash npx eve deploy ``` The deployed app serves the exact same health, session, and stream routes you've been hitting since 1.3, plus the web dashboard from 4.1. If you wired up Slack in 4.2, this is the deploy that gives its webhook a real home. ## Try It Smoke-test the live agent. Health first, it's public, no auth: ```bash curl https:///eve/v1/health ``` ```text { "ok": true } ``` Now prove the fail-closed behavior from 5.1. With no customer cookie or Vercel OIDC token, the deployed production process rejects the request: ```bash curl -i -X POST https:///eve/v1/session \ -H 'content-type: application/json' \ -d '{"message":"hello"}' ``` ```text HTTP/2 401 { "ok": false, "code": "unauthorized" } ``` Then drive a real turn as a customer. The same route accepts the server-recognized demo session, returns `202`, and gives you a `sessionId` to stream: ```bash sid=$(curl -s -X POST https:///eve/v1/session \ -H 'content-type: application/json' -H 'cookie: shop_session=demo-pro' \ -d '{"message":"what does a full overhaul cost?"}' \ | sed -E 's/.*"sessionId":"([^"]+)".*/\1/') curl -sN -H 'cookie: shop_session=demo-pro' \ "https:///eve/v1/session/$sid/stream" ``` Or point the dev TUI at the deployment and talk to it interactively: ```bash npx eve dev https:// ``` The production dispatcher keeps its tools and approval gate. Ask it to book the overhaul and the turn still pauses for approval. \*\*Note: Find your runs in the dashboard\*\* Once deployed, Vercel auto-detects `eve` as the framework and surfaces an **Agent Runs** tab under your project's Observability view. Each conversation is a trace you can open and walk, every tool call, every approval, every turn. (The tab is gated per team in the current release; if you don't see it, ask your Vercel contact to enable it.) A `401` on every live request means the fail-closed lock rejected the caller. Send a valid `shop_session` cookie, or use the public `/eve/v1/health` route for a quick check. If `eve build` fails on discovery, read the printed diagnostics and `.eve/diagnostics.json`. A common cause is a tool file that doesn't `export default` its `defineTool`. ## Done-When - [ ] `agent/sandbox/sandbox.ts` sets a backend (`defaultBackend()` is fine). - [ ] `eve deploy` builds successfully and ships the app. - [ ] `curl https:///eve/v1/health` returns `ok`. - [ ] An unauthenticated session request returns `401`, while `shop_session=demo-pro` returns an accepted session ID. - [ ] A real turn against the deployed URL works (TUI via `eve dev ` or `curl` with a session). ## Solution The only code is the sandbox backend; the rest is one command: ```ts title="agent/sandbox/sandbox.ts" import { defineSandbox, defaultBackend } from "eve/sandbox"; export default defineSandbox({ backend: defaultBackend(), }); ``` ```bash npx eve deploy ``` The dispatcher now runs in production with the persona and capabilities you built throughout the course. The final lesson covers the eve directories this shop did not need and when you might use them. --- title: "Where to Go Next" description: "Recap the dispatcher you built across the 'agent is a directory' steps, and the three eve directories you didn't need yet: connections, subagents, and schedules." canonical_url: "https://vercel.com/academy/building-agents-with-eve/where-to-go-next" md_url: "https://vercel.com/academy/building-agents-with-eve/where-to-go-next.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-29T02:28:26.296Z" content_type: "lesson" course: "building-agents-with-eve" course_title: "Building Agents with eve" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Where to Go Next # Where to Go Next The Spoke & Mirror dispatcher started as a generic persona in a terminal. It now quotes from a catalog, remembers bikes, chooses a playbook by customer tier, and pauses expensive bookings for approval. Customers can reach it on the web or in Slack, and production routes enforce fail-closed auth. You built each capability by adding a file in a predictable place. That is the idea to carry out of this course: **an agent is a directory.** ## What you built, by the folders The dispatcher walked the first six steps of that idea: 1. **Pinned a model** in `agent/agent.ts`. 2. **Started with one file**, the persona in `agent/instructions.md`. 3. **Taught it procedures** with a dynamic skill in `agent/skills/`. 4. **Gave it hands** with five tools in `agent/tools/`. 5. **Let it run code safely** by seeding the `agent/sandbox/` workspace. 6. **Put it where people are** with `agent/channels/` (web + Slack) and real auth. eve supplied the approval queue, streaming protocol, Slack credential handling, and session store. You wrote the shop's behavior. ## What eve was running underneath - **AI Gateway** routed every model call. That `anthropic/claude-opus-4.8` id you pinned back in 1.1 is a gateway route, which is why you never juggled a provider key. - **Workflows** made runs durable. A workflow preserved the $180 booking while it waited for approval in Section 3, then resumed it from the exact step after a yes. - **Sandbox** kept execution isolated. The workspace you seeded in Section 2 ran in an isolated environment. - **Connect** brokered access. Slack reached the agent through Connect's scoped credentials, so tokens never sat in your source. You define the agent as a directory, and eve assembles the production machinery underneath it. **You define the agent; Vercel runs it.** ## The three folders the shop didn't need (yet) This bike shop did not need three of eve's directories. Add one when the corresponding need appears: **`connections/`: when the agent needs a system you don't own.** Right now the catalog is a local file. The day the shop wants live parts availability from a supplier, you'd add a connection to that supplier's MCP or API. The model gets the supplier's tools, and eve brokers the credentials so they never reach the model. Reach for it the first time you think "the agent needs data from *their* system, not ours." **`subagents/`: when one job deserves its own specialist.** Our dispatcher is a generalist. A difficult intermittent-creak diagnosis might benefit from a focused prompt and narrower toolset. Put a diagnosis specialist in `agent/subagents/` and let the dispatcher delegate to it. The child runs in its own context and reports back. Use a subagent when the task needs a different set of instructions and tools. **`schedules/`: when the agent should act on its own clock.** Everything you built waits to be asked. A schedule lets the agent start work on a cadence: a nightly sweep that texts customers whose bikes are ready for pickup, or a Monday digest of the week's bookings to the shop's Slack. One file in `agent/schedules/` with a cron expression. Reach for it the first time you catch yourself wanting the agent to *initiate*. \*\*Note: Follow the directory convention\*\* Each capability starts with a file in its matching folder. Open `node_modules/eve/docs/` for the installed version's details and examples. ## Ship something To keep exploring, change the shop. Add a `report_damage` tool that files a photo, give pros a `bulk_book` skill, or schedule reminders for overdue pickups. The complete dispatcher lives at [vercel-labs/bike-shop-agent](https://github.com/vercel-labs/bike-shop-agent). Diff your build against it if anything drifted, or fork it as the starting point for your own. You can now ship a durable agent that people reach through authenticated channels, and you can inspect its behavior by listing a directory. Build one that makes your work easier. --- title: "Workflow Foundations" description: "Learn the foundations of the Workflow SDK by building a pizza order tracker. Durable, resumable code with two directives and no state machines." canonical_url: "https://vercel.com/academy/workflow-foundations" md_url: "https://vercel.com/academy/workflow-foundations.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-09-22T04:50:17.378Z" content_type: "course" lessons: 13 estimated_time: lesson_urls: - "https://vercel.com/academy/workflow-foundations/set-up-the-pizza-tracker.md" - "https://vercel.com/academy/workflow-foundations/send-a-confirmation-email.md" - "https://vercel.com/academy/workflow-foundations/wrap-it-in-a-workflow.md" - "https://vercel.com/academy/workflow-foundations/deploy-to-vercel.md" - "https://vercel.com/academy/workflow-foundations/add-the-kitchen-step.md" - "https://vercel.com/academy/workflow-foundations/pause-between-steps-with-sleep.md" - "https://vercel.com/academy/workflow-foundations/complete-the-happy-path.md" - "https://vercel.com/academy/workflow-foundations/pause-until-the-kitchen-pings-us.md" - "https://vercel.com/academy/workflow-foundations/hook-the-driver-into-the-flow.md" - "https://vercel.com/academy/workflow-foundations/what-if-the-kitchen-never-responds.md" - "https://vercel.com/academy/workflow-foundations/the-kitchen-is-flaky.md" - "https://vercel.com/academy/workflow-foundations/some-failures-are-final.md" - "https://vercel.com/academy/workflow-foundations/observe-everything.md" --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Workflow Foundations You've built it before. A signup flow that needs to send an email today and a follow-up next week. An order tracker that has to wait for the kitchen, then the driver, then a delivery confirmation. You reach for `setTimeout`, then realize serverless kills your function. You build a cron job. You add a database table to track state. You write retry logic. Three days later you have a brittle pile of code that breaks every time you deploy. This course teaches you how to build that same feature with the Workflow SDK: durable, resumable code that survives deploys, retries automatically, and waits for hours or days without consuming a single millisecond of compute. No new DSL. No state machines. Just async TypeScript with two new directives. ## What You'll Build A pizza order tracker. A customer places an order, a workflow walks it through the kitchen and the driver via real webhooks, retries flaky steps automatically, escalates if the kitchen goes dark, and sends a "rate your pizza" email after delivery. By the end you'll have a working app deployed to Vercel with real Resend emails firing and the real Workflows dashboard showing every run. ## What You'll Learn - Why durable workflows exist and when to reach for one - The two directives that turn regular async functions into durable workflows - How to sequence steps, pause for hours or days, and survive deploys mid-execution - How to wait on real-world events with hooks - How retries work and how to opt out for fatal errors - How to debug workflows in the Vercel dashboard ## Prerequisites - Comfort with Next.js App Router (Route Handlers, Server Components, basic Server Actions) - TypeScript basics (`async`/`await`, type annotations) - A Vercel account - A Resend account (free tier is fine) - Node 20+ and pnpm installed You don't need prior experience with queues, cron jobs, or state machines. If anything, the less of that you've used, the easier this will be. ## Course Sections **Section 1: Your First Workflow.** Set up the pizza tracker, write your first step and workflow, and deploy. By the end of this section you've shipped a one-step workflow to Vercel and watched it run in the dashboard. **Section 2: Multi-Step Orders.** Stack on more steps and introduce `sleep()` to model cook time. Deploy a code change mid-workflow and watch it survive. By the end of this section the tracker walks an order through every state from confirmed to delivered. **Section 3: Waiting for the Real World.** Replace the fake sleeps with real external events. Hooks let the workflow pause until the kitchen or driver actually pings us. By the end the workflow is fully event-driven and handles an unresponsive kitchen gracefully. **Section 4: When Things Go Wrong.** Production isn't kind. Steps fail, services flake, payments decline. This section walks through automatic retries, fatal errors, and the dashboard tools that make production debugging livable. Let's order a pizza. --- title: "Set up the tracker" description: "Clone the starter, install the workflow package, wrap next.config.ts with withWorkflow, and boot the pizza tracker locally." canonical_url: "https://vercel.com/academy/workflow-foundations/set-up-the-pizza-tracker" md_url: "https://vercel.com/academy/workflow-foundations/set-up-the-pizza-tracker.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-06-05T22:31:15.975Z" content_type: "lesson" course: "workflow-foundations" course_title: "Workflow Foundations" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Set up the tracker # Set up the pizza tracker Most fancy backend tools demand a setup ceremony. New CLI, new project, new runtime, new YAML file you'll never look at again. Workflow keeps it suspiciously chill. You install one package. You change one line in `next.config.ts`. That's it. Your existing Next app is now capable of running durable workflows. This lesson is mostly clicking through configuration. We'll clone the pizza tracker starter, install the SDK, and wire up the config. The interesting stuff starts in 1.2 when we write our first step. But before we can do anything, the pizza shop needs to be open for business. ## Outcome Get the pizza tracker starter running locally with the Workflow SDK installed and `next.config.ts` wired up. By the end you'll see the order form at `http://localhost:3000`. ## Fast Track 1. Clone the starter and run `pnpm install`. 2. Add the workflow package: `pnpm add workflow`. Wrap your `next.config.ts` export with `withWorkflow()` from `workflow/next`. 3. Add `RESEND_API_KEY` to `.env.local`. Run `pnpm dev`. ## Hands-on exercise We're working from the course starter repo. It's a Next.js 16 app with a customer order form, a kitchen ops page, a driver ops page, and an in-memory order store. Everything renders but nothing durable happens yet. **1. Install the Workflow SDK.** ```bash pnpm add workflow ``` **2. Wrap your Next config.** The `withWorkflow()` helper from `workflow/next` is what teaches Next.js to handle the `"use workflow"` and `"use step"` directives. Without it, those directives are silently ignored. Update `next.config.ts`: ```ts title="next.config.ts" {1,8} import { withWorkflow } from "workflow/next"; import type { NextConfig } from "next"; const nextConfig: NextConfig = { experimental: {}, }; export default withWorkflow(nextConfig); ``` **3. (Optional) Turn on the TypeScript plugin.** If you want IntelliSense for workflow primitives, add the plugin to `tsconfig.json`: ```json title="tsconfig.json" {4} { "compilerOptions": { "plugins": [ { "name": "next" }, { "name": "workflow" } ] } } ``` **4. Add your Resend key.** ```bash title=".env.local" RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxx ``` Grab one from [resend.com/api-keys](https://resend.com/api-keys). The free tier sends from `onboarding@resend.dev` and works fine for the rest of the course. You don't need to verify a domain. **5. Boot it.** ```bash pnpm dev ``` ## Try It Open `http://localhost:3000`. You should see Sal's Pizza order form pre-filled with Marge Pepperoni's details and a Carbonara waiting to be ordered. Don't click the button yet. Right now `/api/orders` just generates a fake `runId`, stores the order in memory, and redirects you to a status page. No workflow is running. That's our job in the next three lessons. Hover over the form fields. The starter already has reasonable defaults so you don't have to type Marge's address every time you test the flow. You'll be placing a lot of orders this course. \*\*Note: Why the wrap matters\*\* Without `withWorkflow()`, your `"use workflow"` and `"use step"` directives compile down to no-ops. The wrap installs a Next.js plugin that finds those directives at build time and compiles each step into its own isolated route. Skip the wrap and you'll wonder for an hour why nothing works. ## Commit ``` feat(setup): install Workflow SDK and wrap next.config.ts ``` ## Done-When - [ ] `pnpm install` completes without errors - [ ] `workflow` is in your `package.json` dependencies - [ ] `next.config.ts` wraps the export with `withWorkflow()` - [ ] `.env.local` contains your `RESEND_API_KEY` - [ ] `pnpm dev` boots and `http://localhost:3000` shows the order form ## Solution `next.config.ts`: ```ts title="next.config.ts" import { withWorkflow } from "workflow/next"; import type { NextConfig } from "next"; const nextConfig: NextConfig = { experimental: {}, }; export default withWorkflow(nextConfig); ``` `package.json` (relevant entry): ```json title="package.json" { "dependencies": { "next": "16.2.6", "react": "19.0.0", "react-dom": "19.0.0", "resend": "6.12.4", "workflow": "4.2.5" } } ``` `tsconfig.json` (relevant entry): ```json title="tsconfig.json" { "compilerOptions": { "plugins": [ { "name": "next" }, { "name": "workflow" } ] } } ``` `.env.local`: ```bash title=".env.local" RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxx ``` That's the entire setup. One package, one wrap, one env var. The next lesson is where we actually do something durable. --- title: "Send a confirmation" description: "Write the sendOrderConfirmation step with the \"use step\" directive, learn what the directive promises, and wire it into the orders Route Handler." canonical_url: "https://vercel.com/academy/workflow-foundations/send-a-confirmation-email" md_url: "https://vercel.com/academy/workflow-foundations/send-a-confirmation-email.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-06-05T22:31:15.997Z" content_type: "lesson" course: "workflow-foundations" course_title: "Workflow Foundations" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Send a confirmation # Send a confirmation email Here's the trick of the Workflow SDK: directives don't change your code. They change what the runtime does with your code. Your first step today is `sendOrderConfirmation`. It calls Resend. It sends an email. We could write it as a plain async function and it would work fine, until Resend hiccups. Then the request fails, the function returns, and Marge Pepperoni never gets her confirmation. The usual fix is the usual ceremony: `try`, `catch`, `setTimeout`, retry counter, hope. Or we can write three letters. Add `"use step"` to the top of the function. The function body is identical. Same Resend call, same params. But now the runtime has a contract with that function: this is a unit of work, track it, retry it on failure, log the inputs and outputs. The directive doesn't change what your function does. It changes what happens around it. ## Outcome Create `workflows/steps/send-order-confirmation.ts` as your first step, then wire it into `/api/orders` so placing an order sends a real Resend email. ## Fast Track 1. Create `workflows/steps/send-order-confirmation.ts`. Export an async function `sendOrderConfirmation(order: Order)` that calls Resend and throws on failure. Put `"use step"` as the first statement in the body. 2. In `app/api/orders/route.ts`, import the step and `await sendOrderConfirmation(order)` before the redirect. 3. Place an order. Check your inbox. ## Hands-on exercise Steps live in `workflows/steps/` by convention. One step per file keeps things tidy when you get into the dashboard later. **1. Write the step.** Create `workflows/steps/send-order-confirmation.ts`: ```ts title="workflows/steps/send-order-confirmation.ts" import { FatalError } from "workflow"; import { resend, FROM } from "@/lib/resend"; import { updateStatus } from "@/lib/orders-store"; import type { Order } from "@/lib/pizza"; export async function sendOrderConfirmation(order: Order): Promise { "use step"; const resp = await resend.emails.send({ from: FROM, to: [order.email], subject: `Order confirmed: ${order.size} ${order.pizza}`, html: `

Hi ${order.customerName},

We got your order for a ${order.size} ${order.pizza} on ${order.crust} crust.

We'll let you know when it's out for delivery to ${order.address}.

Sal

`, }); if (resp.error) { throw new FatalError(`Resend failed: ${resp.error.message}`); } updateStatus(order.id, "confirmed"); } ``` A few things worth noting. `"use step"` is the first statement in the function body, like `"use strict"`. Anywhere else it does nothing. We use `FatalError` from `workflow` when Resend reports a real failure (like a malformed email address). That class tells the runtime: don't retry, this won't work. We'll come back to this in 4.2. For now: if the call returns an error, we want it to be final. Everything else is a regular Resend call. No new APIs. No special handling. The directive is the only thing that makes this a step. **2. Wire it into the route.** The starter's `/api/orders` stores the order and returns a fake `runId`. Add a call to our new step right before the response: ```ts title="app/api/orders/route.ts" {3,29} import { NextResponse } from "next/server"; import { recordOrder } from "@/lib/orders-store"; import { sendOrderConfirmation } from "@/workflows/steps/send-order-confirmation"; import type { Order, PizzaName, Size, Crust } from "@/lib/pizza"; // ... type IncomingOrder unchanged ... export async function POST(request: Request) { const body = (await request.json()) as IncomingOrder; const order: Order = { id: crypto.randomUUID(), customerName: body.customerName, email: body.email, pizza: body.pizza, size: body.size, crust: body.crust, address: body.address, cardLast4: body.cardLast4, placedAt: new Date().toISOString(), }; // TODO (Lesson 1.3): Replace this stub with start(processOrder, [order]). const fakeRunId = crypto.randomUUID(); recordOrder(order, fakeRunId); await sendOrderConfirmation(order); return NextResponse.json({ runId: fakeRunId }); } ``` \*\*Note: The directive isn't active yet\*\* Calling a `"use step"` function directly, like we just did, runs it as a regular function. No retries. No event log. The directive becomes meaningful in the next lesson when we call this from inside a workflow. Right now we're laying the wiring. ## Try It Open `http://localhost:3000`. Put your real email in the form. Click **Place order**. Two things should happen: 1. The browser redirects to `/orders/` and shows the order details. 2. A confirmation email lands in your inbox from `onboarding@resend.dev`. If you don't see the email, check the dev server output. Resend logs go through the standard console, and a missing or invalid `RESEND_API_KEY` shows up as a 401 from their API. Try ordering a few times. Different pizzas. Different sizes. The email subject should reflect what you ordered. Marge would want her Carbonara confirmation to say "Carbonara," not "Margherita." ## Commit ``` feat(workflow): add sendOrderConfirmation step ``` ## Done-When - [ ] `workflows/steps/send-order-confirmation.ts` exists with `"use step"` at the top of the function body - [ ] The step throws a `FatalError` when Resend returns an error - [ ] `app/api/orders/route.ts` imports and awaits `sendOrderConfirmation(order)` - [ ] Placing an order from the UI delivers an actual email to the address you entered ## Solution `workflows/steps/send-order-confirmation.ts`: ```ts title="workflows/steps/send-order-confirmation.ts" import { FatalError } from "workflow"; import { resend, FROM } from "@/lib/resend"; import { updateStatus } from "@/lib/orders-store"; import type { Order } from "@/lib/pizza"; export async function sendOrderConfirmation(order: Order): Promise { "use step"; const resp = await resend.emails.send({ from: FROM, to: [order.email], subject: `Order confirmed: ${order.size} ${order.pizza}`, html: `

Hi ${order.customerName},

We got your order for a ${order.size} ${order.pizza} on ${order.crust} crust.

We'll let you know when it's out for delivery to ${order.address}.

Sal

`, }); if (resp.error) { throw new FatalError(`Resend failed: ${resp.error.message}`); } updateStatus(order.id, "confirmed"); } ``` The function works. The email sends. The directive is sitting there, dormant, waiting for a workflow to bring it to life. That's next. --- title: "Wrap in a workflow" description: "Write the processOrder workflow with \"use workflow\", trigger it from the orders Route Handler using start(), and tour the local Workflow dashboard." canonical_url: "https://vercel.com/academy/workflow-foundations/wrap-it-in-a-workflow" md_url: "https://vercel.com/academy/workflow-foundations/wrap-it-in-a-workflow.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-06-05T22:31:16.019Z" content_type: "lesson" course: "workflow-foundations" course_title: "Workflow Foundations" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Wrap in a workflow # Wrap it in a workflow Notice what your orders route currently does: it awaits the Resend call. The customer's browser sits there spinning while the SMTP handshake completes. If Resend is slow, the order placement is slow. If Resend is down, the order placement fails. That's the part workflows fix. When we wrap the step in a workflow and start it with `start()`, the route returns the moment the workflow is queued. The step runs in the background. The customer gets their redirect immediately. The email arrives whenever Resend is ready to send it. And if Resend has a bad day, the runtime retries without anyone knowing. `"use workflow"` is the second directive. It marks a function as the orchestrator, the one allowed to call steps. Steps inside a workflow get the full treatment: automatic retries, an event log, observability. Steps outside a workflow are decorative. Let's wake ours up. ## Outcome Create `workflows/process-order.ts` with the `processOrder` workflow that calls `sendOrderConfirmation`. Replace the direct step call in `/api/orders` with `start(processOrder, [order])`. Watch the first real workflow run in the local dashboard. ## Fast Track 1. Create `workflows/process-order.ts`. Export an async `processOrder(order)` with `"use workflow"` as the first statement. Call `sendOrderConfirmation(order)` inside it. 2. In `/api/orders`, replace the direct step call and the fake `runId` with `const run = await start(processOrder, [order])`. Use `run.runId`. 3. In another terminal, run `pnpm exec workflow web`. Place an order. Watch the run land. ## Hands-on exercise **1. Write the workflow.** Create `workflows/process-order.ts`: ```ts title="workflows/process-order.ts" import type { Order } from "@/lib/pizza"; import { sendOrderConfirmation } from "./steps/send-order-confirmation"; export async function processOrder(order: Order): Promise<{ orderId: string }> { "use workflow"; await sendOrderConfirmation(order); return { orderId: order.id }; } ``` Three things to call out: `"use workflow"` is the first statement in the body, mirroring `"use step"`. The runtime looks for it at build time and treats the function as a workflow definition. The workflow imports steps and `await`s them. From the inside it looks like a regular async function calling other async functions. The runtime is doing the heavy lifting: each step runs in its own isolated environment, the result gets persisted, and if the step fails it gets retried automatically. The return value gets persisted too. When the workflow finishes, anyone holding the `runId` can fetch the result later. **2. Replace the route.** Open `app/api/orders/route.ts`. The starter version generates a fake `runId` and calls the step directly. We're going to do neither. ```ts title="app/api/orders/route.ts" {1,4,26} import { start } from "workflow/api"; import { NextResponse } from "next/server"; import { recordOrder } from "@/lib/orders-store"; import { processOrder } from "@/workflows/process-order"; import type { Order, PizzaName, Size, Crust } from "@/lib/pizza"; type IncomingOrder = { customerName: string; email: string; pizza: PizzaName; size: Size; crust: Crust; address: string; cardLast4: string; }; export async function POST(request: Request) { const body = (await request.json()) as IncomingOrder; const order: Order = { id: crypto.randomUUID(), ...body, placedAt: new Date().toISOString(), }; const run = await start(processOrder, [order]); recordOrder(order, run.runId); return NextResponse.json({ runId: run.runId }); } ``` `start(processOrder, [order])` enqueues the workflow with `order` as its first argument. It returns a `Run` object. `run.runId` is available synchronously, with no extra `await` on that property. The workflow itself runs in the background; we don't wait for it. We also delete the direct `sendOrderConfirmation` import. The step is now called from inside the workflow, which is where it belongs. **3. Open the dashboard.** In a second terminal: ```bash pnpm exec workflow web ``` That boots the local Workflow dashboard at `http://localhost:3700`. Leave it open. ## Try It Place an order from `http://localhost:3000`. Two things happen: The browser response is faster than before. The route no longer waits for Resend, so the redirect happens as soon as the workflow is queued. In the dashboard at `http://localhost:3700`, a new run appears. Click into it. You'll see a timeline: ``` processOrder completed └─ sendOrderConfirmation completed input: { id: "0a4f…", pizza: "Carbonara", ... } output: undefined attempts: 1 ``` The email still arrives. Same Resend call as before. But now you've got a record of it. Every step input, every step output, every attempt is logged. \*\*Note: Try a bad email\*\* Put `nope@invalid.fake` in the email field and place an order. Watch the run go red in the dashboard. Click in: the step shows the `FatalError` we threw, with the original Resend error message. That's observability you didn't have to write. ## Commit ``` feat(workflow): wrap order processing in a workflow ``` ## Done-When - [ ] `workflows/process-order.ts` exists with `"use workflow"` and calls `sendOrderConfirmation` - [ ] `/api/orders` calls `start(processOrder, [order])` and uses `run.runId` - [ ] Placing an order from the UI sends an email AND creates a run in the local dashboard - [ ] A failing email (bad address) shows up as a red run with the FatalError visible ## Solution `workflows/process-order.ts`: ```ts title="workflows/process-order.ts" import type { Order } from "@/lib/pizza"; import { sendOrderConfirmation } from "./steps/send-order-confirmation"; export async function processOrder(order: Order): Promise<{ orderId: string }> { "use workflow"; await sendOrderConfirmation(order); return { orderId: order.id }; } ``` `app/api/orders/route.ts`: ```ts title="app/api/orders/route.ts" import { start } from "workflow/api"; import { NextResponse } from "next/server"; import { recordOrder } from "@/lib/orders-store"; import { processOrder } from "@/workflows/process-order"; import type { Order, PizzaName, Size, Crust } from "@/lib/pizza"; type IncomingOrder = { customerName: string; email: string; pizza: PizzaName; size: Size; crust: Crust; address: string; cardLast4: string; }; export async function POST(request: Request) { const body = (await request.json()) as IncomingOrder; const order: Order = { id: crypto.randomUUID(), customerName: body.customerName, email: body.email, pizza: body.pizza, size: body.size, crust: body.crust, address: body.address, cardLast4: body.cardLast4, placedAt: new Date().toISOString(), }; const run = await start(processOrder, [order]); recordOrder(order, run.runId); return NextResponse.json({ runId: run.runId }); } ``` One step. One workflow. One `start()` call. That's the entire setup. Everything else this course teaches is variations on this skeleton. --- title: "Deploy to Vercel" description: "Deploy your Slack bot to Vercel, configure environment variables, handle the URL verification challenge, and update your manifest for production. This lesson takes your bot from local development to a live production environment." canonical_url: "https://vercel.com/academy/workflow-foundations/deploy-to-vercel" md_url: "https://vercel.com/academy/workflow-foundations/deploy-to-vercel.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-06-05T22:31:16.041Z" content_type: "lesson" course: "workflow-foundations" course_title: "Workflow Foundations" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Deploy to Vercel # Deploy Your Bot to Production on Vercel Running `slack run` locally is fine for development, but your bot needs to be available 24/7. When a major incident hits and the team needs instant AI assistance, your bot better be online. Production deployment means real URLs, proper secrets management, and zero downtime during Slack's URL verification handshake. ## Outcome Deploy your bot to Vercel with working event handling, proper environment variables, and a verified Slack Events URL. ## Fast Track 1. Link or create your Vercel project and deploy: `pnpm dlx vercel --prod` 2. Update manifest with production URLs and reinstall 3. Verify bot responds in a real Slack channel ## Deployment Pipeline ``` ┌─────────────────────────────────────────────────────────────────┐ │ Local → Production Flow │ └─────────────────────────────────────────────────────────────────┘ Local Development Production Deployment ↓ ↓ ┌─────────────────┐ ┌─────────────────┐ │ slack run │ │ pnpm dlx vercel │ │ (ngrok tunnel) │ │ --prod │ └─────────────────┘ └─────────────────┘ ↓ ↓ ┌─────────────────┐ ┌─────────────────┐ │ manifest.json │ UPDATE URLs │ manifest.json │ │ localhost:3000 │ ──────────────→│ yourapp.vercel │ │ │ │ .app │ └─────────────────┘ └─────────────────┘ ↓ ↓ ┌─────────────────┐ ┌─────────────────┐ │ Test locally │ │ Slack URL │ │ @bot ping │ │ Verification │ │ ✓ works │ │ Challenge │ └─────────────────┘ └─────────────────┘ ↓ ┌─────────────────┐ │ Production │ │ Bot Live │ │ @bot ping ✓ │ └─────────────────┘ Environment Variables Flow: Local: .env file → process.env Production: Vercel Dashboard → Function runtime ``` ## Building on Previous Lessons This deployment brings together every pattern from the course into production: - **From [Project Setup](./sandbox-repo-setup-smoke-test)**: You already have your own repo (and likely a Vercel project from the Deploy button); now you'll make that deployment production-ready - **From [repository flyover](./repository-flyover)**: Stateless request-response architecture enables horizontal scaling - Vercel can spawn thousands of function instances - **From [correlation middleware](./bolt-nitro-middleware-and-logging)**: Correlation middleware tracks requests across distributed function executions - **From [ack semantics](./acknowledgment-and-latency)**: Ack-first pattern prevents timeouts in serverless environments with cold starts - **From [slash commands](./slash-commands)**: All interaction surfaces (commands, shortcuts, modals) work identically in serverless - **From [AI tools](./ai-tools-and-functions)**: Active context fetching and tool calls (no shared memory) are essential for stateless functions - **From [system prompts](./system-prompts-shape-behavior)**: AI orchestration with retries, status, and fallbacks handles real production traffic - **Production reality**: Each Slack event spawns a fresh function execution - zero shared state, perfect for serverless deployment ## Hands-On Exercise 5.1 Deploy your bot to Vercel and verify it handles production traffic: **Requirements:** 1. Deploy to Vercel with all environment variables 2. Update manifest URLs to point to production domain 3. Pass Slack's URL verification challenge 4. Verify bot responds to mentions in a public channel 5. Check logs show correlation IDs and structured data **Implementation hints:** - Vercel automatically exposes env vars to your app - The `/api/slack/events` route must handle both verification and events - Watch for the verification challenge in Vercel Function logs - Test with a real `@mention` in a non-DM channel **Environment variables needed:** ```bash SLACK_BOT_TOKEN=xoxb-... SLACK_SIGNING_SECRET=... ``` For production you may be using a **different** Slack app than your sandbox one (for example, if `slack manifest update` created a new app). In any case, get the correct secrets from your Slack app config at `https://api.slack.com/apps`: - **Signing Secret**: App → **Basic Information** → **App Credentials** - **Bot token**: App → **Install App** → **Bot User OAuth Token** \*\*Note: AI Gateway Auth in Production\*\* In local development, you configure `AI_GATEWAY_API_KEY` so the AI SDK can talk to Vercel AI Gateway. In production on Vercel, the **recommended path** is to rely on the platform’s automatically generated **OIDC token** – the AI SDK will pick this up as long as you use plain string model IDs (for example, `'openai/gpt-4.1'` or `'xai/grok-3'`) and have Gateway enabled for the project. Sharp edges to watch out for: - You **won’t** see the OIDC token in `process.env` – it’s injected at the platform/runtime level. - If you set `AI_GATEWAY_API_KEY` in production, Gateway will use that key instead of OIDC (which is fine, but now you own key rotation). - Make sure you’re deploying to the same Vercel project you configured Gateway for, or you’ll see auth errors even though “it works locally”. To inspect this behavior later: - Open your project’s security settings: [Secure Backend Access with OIDC Federation](https://vercel.com/d?to=%2F%5Bteam%5D%2F%5Bproject%5D%2Fsettings%2Fsecurity\&title=Secure+Backend+Access+with+OIDC+Federation) - Open the AI Gateway dashboard: [AI Gateway Dashboard](https://vercel.com/d?to=%2F%5Bteam%5D%2F%5Bproject%5D%2Fai%2Fgateway\&title=AI+Gateway) See the official docs for details: [AI Gateway Authentication – OIDC token](https://vercel.com/docs/ai-gateway/authentication#oidc-token) and this AI SDK course's setup guide’s Gateway step ([AI SDK dev setup – Step 4](https://vercel.com/academy/ai-sdk/ai-sdk-dev-setup#step-4-setting-up-the-vercel-ai-gateway)). ## Try It 1. **Deploy to Vercel (link or create project):** ```bash pnpm dlx vercel --prod ``` Expected output: ``` Vercel CLI 28.5.5 ? Set up and deploy ""? [Y/n] y ? Which scope do you want to deploy to? Your Team # If you already created a project via the Deploy with Vercel button, choose that existing project here. # Otherwise, create a new project for this repo. ? What's your project's name? slack-bot-prod ? In which directory is your code located? ./ Auto-detected Project Settings (Nitro): - Build Command: npm run build - Output Directory: .output - Development Command: npm run dev ? Want to override the settings? [y/N] n 🔗 Linked to yourteam/slack-bot-prod (created .vercel) 🔍 Inspect: https://vercel.com/yourteam/slack-bot-prod/abc123 ✅ Production: https://slack-bot-prod.vercel.app [21s] ``` 2. **Update manifest.json:** ```json { "features": { "app_home": { "home_tab_enabled": false, "messages_tab_enabled": true } }, "oauth_config": { "scopes": { "bot": [ "app_mentions:read", "channels:history", "chat:write", "groups:history", "im:history", "mpim:history" ] } }, "settings": { "event_subscriptions": { "request_url": "https://.vercel.sh/api/slack/events", "bot_events": ["app_mention", "message.channels", "message.groups", "message.im", "message.mpim"] } } } ``` 3. **Reinstall app and verify URL:** ```bash slack manifest update slack install ``` In Slack App config, you'll see: ``` Request URL: https://.vercel.sh/api/slack/events Your URL has been verified ✓ ``` 4. **Test in production channel:** ``` You: @bot-name what's the deployment status? Bot: I'm successfully deployed to production! Here's my status: - Environment: Production (Vercel) - Correlation ID: ev09E5EDA89M_1234567890.123456 - Response time: 1.2s - All systems operational ✓ ``` 5. **Verify logs in Vercel dashboard:** ``` [INFO] bolt-app { correlationId: 'ev09E5EDA89M_1234567890.123456', event_id: 'Ev09E5EDA89M', type: 'app_mention', channel: 'C09D4DG727P', thread_ts: '1234567890.123456', user: 'U0123ABCDEF' } Processing app_mention event [INFO] bolt-app { correlationId: 'ev09E5EDA89M_1234567890.123456', operation: 'respondToMessage', model: 'openai/gpt-4o-mini', promptTokens: 156, completionTokens: 47, latencyMs: 892 } AI response generated successfully ``` ## Troubleshooting **URL Verification Fails:** - Check your signing secret is correctly set in Vercel env vars - Ensure the route is `/api/slack/events` (not `/api/events`) - Look for the challenge parameter in Vercel Function logs **Bot Doesn't Respond:** - Verify `SLACK_BOT_TOKEN` starts with `xoxb-` - Check bot is in the channel (invite with `/invite @bot-name`) - Confirm manifest has `app_mentions:read` scope **Environment Variables Missing:** ```bash # List current env vars for your project pnpm dlx vercel env ls # Add missing Slack secrets (production) pnpm dlx vercel env add SLACK_BOT_TOKEN production pnpm dlx vercel env add SLACK_SIGNING_SECRET production # (Optional) Add AI gateway key if you want to override OIDC-based auth pnpm dlx vercel env add AI_GATEWAY_API_KEY production ``` ## Commit ```bash git add -A git commit -m "feat(deploy): production deployment to Vercel with URL verification - Configure Vercel deployment with Nitro preset - Update manifest URLs to production domain - Handle Slack URL verification challenge - Verify bot responds in production channels - Structured logs with correlation IDs working" ``` ## Done-When - [x] Bot deployed to Vercel and accessible via public URL - [x] Slack Events URL verified (green checkmark in app config) - [x] Bot responds to mentions in production channel - [x] Logs show correlation IDs and structured fields - [x] All environment variables properly configured ## Solution The key parts for production deployment: 1. **Vercel deployment command:** ```bash pnpm dlx vercel --prod ``` 2. **Updated manifest.json with production URL:** ````json title="/slack-agent/manifest.json" {15} ```json title="/slack-agent/manifest.json" {15} { "display_information": { "name": "AI Assistant Bot", "description": "Production AI assistant with context awareness", "background_color": "#1a1a2e" }, "features": { "app_home": { "home_tab_enabled": false, "messages_tab_enabled": true } }, "settings": { "event_subscriptions": { "request_url": "https://.vercel.sh/api/slack/events", "bot_events": [ "app_mention", "message.channels", "message.groups", "message.im", "message.mpim" ] } }, "oauth_config": { "scopes": { "bot": [ "app_mentions:read", "channels:history", "chat:write", "groups:history", "im:history", "mpim:history" ] } } } ```` 3. **Environment variables in Vercel:** All secrets are added through Vercel dashboard or CLI: ```bash pnpm dlx vercel env add SLACK_BOT_TOKEN production pnpm dlx vercel env add SLACK_SIGNING_SECRET production ``` 4. **Verification of deployment:** Check Function logs in Vercel dashboard for: - URL verification challenge handled - Events being received - Correlation IDs in structured logs - Response times under 3 seconds \*\*Side Quest: Zero-Downtime Deployment Pipeline\*\* ## Key Takeaways - Production deployment requires proper URL configuration in both Vercel and Slack - The URL verification handshake must complete within 3 seconds - Environment variables must be set in Vercel, not just locally - Structured logging with correlation IDs is essential for debugging production issues - Always test in a real channel after deployment --- title: "Add the kitchen step" description: "Write the acknowledgeKitchen step and call it from processOrder so the workflow has two steps in sequence." canonical_url: "https://vercel.com/academy/workflow-foundations/add-the-kitchen-step" md_url: "https://vercel.com/academy/workflow-foundations/add-the-kitchen-step.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-06-05T22:31:16.084Z" content_type: "lesson" course: "workflow-foundations" course_title: "Workflow Foundations" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Add the kitchen step # Add the kitchen step One step makes a workflow. Two steps makes the value obvious. Right now `processOrder` calls `sendOrderConfirmation` and returns. The kitchen has no idea there's an order. The customer thinks Sal is making their pizza, but Sal is, in fact, taking a nap. Time to add `acknowledgeKitchen`. It updates the order status to "in-the-kitchen" so the ops dashboard knows. In a real shop this is where you'd ping a kitchen printer or your POS. We're keeping it boring on purpose. The point isn't what the step does. The point is that adding another step to a workflow takes two lines. ## Outcome Add `acknowledgeKitchen` as a second step in `processOrder`. After placing an order, the run timeline shows two steps and the kitchen ops page shows the order waiting in the oven. ## Fast Track 1. Create `workflows/steps/acknowledge-kitchen.ts` with the `"use step"` directive. Update the store status to `"in-the-kitchen"`. 2. Import the step in `workflows/process-order.ts`. `await` it after `sendOrderConfirmation`. 3. Place an order. Open `/kitchen` and see it sitting in the oven. ## Hands-on exercise **1. Write the step.** Create `workflows/steps/acknowledge-kitchen.ts`: ```ts title="workflows/steps/acknowledge-kitchen.ts" import { updateStatus } from "@/lib/orders-store"; import type { Order } from "@/lib/pizza"; export async function acknowledgeKitchen(order: Order): Promise { "use step"; await new Promise((resolve) => setTimeout(resolve, 200)); updateStatus(order.id, "in-the-kitchen"); } ``` Same shape as `sendOrderConfirmation`. Async function, `"use step"` first, do the work, persist any side effect through the store. The 200ms `setTimeout` is fake latency so the dashboard timeline has something to show. Real code wouldn't have it. \*\*Note: Why no try/catch?\*\* We didn't wrap the body in error handling. If the store update throws, the step throws, and the runtime retries. That's the whole deal. You only catch errors when you have a specific recovery plan, which here you don't. **2. Call it from the workflow.** Add the import and a second `await` in `workflows/process-order.ts`: ```ts title="workflows/process-order.ts" {2,8} import type { Order } from "@/lib/pizza"; import { sendOrderConfirmation } from "./steps/send-order-confirmation"; import { acknowledgeKitchen } from "./steps/acknowledge-kitchen"; export async function processOrder(order: Order): Promise<{ orderId: string }> { "use workflow"; await sendOrderConfirmation(order); await acknowledgeKitchen(order); return { orderId: order.id }; } ``` Two lines: an import and an `await`. That's the entire change. ## Try It Place an order from `http://localhost:3000`. Then open `http://localhost:3000/kitchen` in a new tab. The kitchen page should show your order sitting in the oven: ``` 1 pizza in the oven. large Sal's Special [Mark ready] Marge Pepperoni · thin crust ``` The "Mark ready" button doesn't work yet. Clicking it 404s because we haven't built `/api/kitchen/ready`. That's section 3. For now, the order sits there forever, which is exactly what the kitchen would tell you if you called. In the dashboard at `http://localhost:3700`, the run timeline now shows two steps: ``` processOrder completed 612ms ├─ sendOrderConfirmation completed 389ms └─ acknowledgeKitchen completed 208ms ``` The order doesn't progress past "in-the-kitchen" because the workflow returns right after that step. We'll keep extending it. ## Commit ``` feat(workflow): add acknowledgeKitchen step ``` ## Done-When - [ ] `workflows/steps/acknowledge-kitchen.ts` exists with the `"use step"` directive - [ ] `processOrder` awaits `acknowledgeKitchen` after `sendOrderConfirmation` - [ ] Placing an order results in the kitchen page showing the order in the oven - [ ] The dashboard timeline shows two completed steps for the run ## Solution `workflows/steps/acknowledge-kitchen.ts`: ```ts title="workflows/steps/acknowledge-kitchen.ts" import { updateStatus } from "@/lib/orders-store"; import type { Order } from "@/lib/pizza"; export async function acknowledgeKitchen(order: Order): Promise { "use step"; await new Promise((resolve) => setTimeout(resolve, 200)); updateStatus(order.id, "in-the-kitchen"); } ``` `workflows/process-order.ts`: ```ts title="workflows/process-order.ts" import type { Order } from "@/lib/pizza"; import { sendOrderConfirmation } from "./steps/send-order-confirmation"; import { acknowledgeKitchen } from "./steps/acknowledge-kitchen"; export async function processOrder(order: Order): Promise<{ orderId: string }> { "use workflow"; await sendOrderConfirmation(order); await acknowledgeKitchen(order); return { orderId: order.id }; } ``` Same skeleton, more steps. We'll repeat this pattern for the rest of the course. --- title: "Pause with sleep()" description: "Add sleep() between the kitchen step and the rest of the workflow. Deploy a code change mid-sleep and watch the workflow survive untouched." canonical_url: "https://vercel.com/academy/workflow-foundations/pause-between-steps-with-sleep" md_url: "https://vercel.com/academy/workflow-foundations/pause-between-steps-with-sleep.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-06-05T22:31:16.107Z" content_type: "lesson" course: "workflow-foundations" course_title: "Workflow Foundations" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Pause with sleep() # Pause between steps with sleep() Pizzas take time to cook. The workflow has no idea. If we want it to wait for the kitchen before dispatching a driver, we need a way to pause. In a regular Node app, pausing means a `setTimeout` and praying the process stays alive. In a serverless function it means... well, you don't pause. Your function dies after 10 seconds. Workflows have `sleep()`. You await it like any other promise. The workflow suspends. Vercel stops running anything related to that workflow, no compute, no memory, nothing. When the time elapses, the workflow picks up exactly where it left off. Today we're going to pause for 30 seconds between the kitchen ack and the next step. Boring on paper. The reason to pay attention: while the workflow is sleeping, we're going to deploy a code change. The workflow should survive it untouched. ## Outcome Add `await sleep("30s")` to `processOrder` after the kitchen step. Deploy a change while a workflow is mid-sleep and watch the original run complete cleanly. ## Fast Track 1. Import `sleep` from `workflow` in `workflows/process-order.ts`. Add `await sleep("30s")` after `acknowledgeKitchen`. 2. Deploy. Place an order. While the dashboard shows the workflow suspended, change the confirmation email subject and push another deploy. 3. Watch the original run finish on the new build without re-running the steps that already completed. ## Hands-on exercise **1. Add sleep.** Update `workflows/process-order.ts`: ```ts title="workflows/process-order.ts" {1,10} import { sleep } from "workflow"; import type { Order } from "@/lib/pizza"; import { sendOrderConfirmation } from "./steps/send-order-confirmation"; import { acknowledgeKitchen } from "./steps/acknowledge-kitchen"; export async function processOrder(order: Order): Promise<{ orderId: string }> { "use workflow"; await sendOrderConfirmation(order); await acknowledgeKitchen(order); await sleep("30s"); return { orderId: order.id }; } ``` `sleep()` accepts a duration string like `"30s"`, `"5m"`, `"7 days"`, or a number of milliseconds. We're using 30 seconds because it's long enough to deploy through. **2. Deploy.** ```bash git add . git commit -m "feat(workflow): pause after kitchen ack" git push ``` Wait for Vercel to finish the deploy. ## Try It This is the experiment. Pay attention to the timestamps. **Place an order on your production URL.** The workflow runs `sendOrderConfirmation`, then `acknowledgeKitchen`, then hits `sleep("30s")` and suspends. **Open the Workflows dashboard** in your Vercel project. The run shows as `running` with the timeline ending at the sleep: ``` processOrder running ├─ sendOrderConfirmation completed └─ acknowledgeKitchen completed ─ sleeping 30s ``` **While that's suspended, change something.** Open `workflows/steps/send-order-confirmation.ts` and edit the email subject. Add an emoji. Add "v2". Anything. Then: ```bash git commit -am "tweak: update confirmation subject" git push ``` Vercel builds and ships the new version. **Now go back to the original run.** The sleep elapses. The workflow wakes up on the new deploy. It finishes: ``` processOrder completed 30.4s ├─ sendOrderConfirmation completed 389ms └─ acknowledgeKitchen completed 208ms (slept 30s, resumed after deploy) ``` The completed steps don't re-run. The new code is now serving new orders, but the old order finishes on the timeline it started with. This is the unfair part of durable execution. It's also why the directives exist. \*\*Note: What just happened\*\* Two things kept the original workflow safe across a deploy: every step's input/output was already persisted to the event log, and `sleep()` doesn't hold a process open. When Vercel restarted the workflow on the new deploy, it replayed the workflow function from the top, hit each completed step, looked up the recorded result, and skipped ahead. It only actually executed code starting from where the workflow had paused. ## Commit ``` feat(workflow): pause after kitchen with sleep() ``` ## Done-When - [ ] `workflows/process-order.ts` imports `sleep` from `workflow` - [ ] The workflow awaits `sleep("30s")` after `acknowledgeKitchen` - [ ] You've shipped a code change while a workflow was mid-sleep - [ ] The original run completed on the new deploy without re-running prior steps ## Solution ```ts title="workflows/process-order.ts" import { sleep } from "workflow"; import type { Order } from "@/lib/pizza"; import { sendOrderConfirmation } from "./steps/send-order-confirmation"; import { acknowledgeKitchen } from "./steps/acknowledge-kitchen"; export async function processOrder(order: Order): Promise<{ orderId: string }> { "use workflow"; await sendOrderConfirmation(order); await acknowledgeKitchen(order); await sleep("30s"); return { orderId: order.id }; } ``` One line. Workflow sleeps. Deploys don't matter. This is the closest thing this course has to a magic trick. --- title: "Complete the path" description: "Add dispatchDelivery, confirmDelivery, and sendReviewRequest steps with sleeps in between so the workflow walks an order from confirmation to review." canonical_url: "https://vercel.com/academy/workflow-foundations/complete-the-happy-path" md_url: "https://vercel.com/academy/workflow-foundations/complete-the-happy-path.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-06-05T22:31:16.129Z" content_type: "lesson" course: "workflow-foundations" course_title: "Workflow Foundations" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Complete the path # Complete the happy path Three more steps and the pizza is at Marge's door. We've got the order confirmed and the kitchen acknowledged. Time to dispatch a driver, wait for the delivery, and ask Marge to rate her Carbonara. Each one is a step function called from the workflow, with a `sleep()` modeling whatever the real-world delay would be. We're padding the timeline with fake sleeps because we don't have driver webhooks yet. That changes in 3.2. For now: sleep, sleep, sleep, like a real Tuesday. ## Outcome Add `dispatchDelivery`, `confirmDelivery`, and `sendReviewRequest` steps. The workflow now walks an order from "in-the-kitchen" all the way to "review-sent" with sleeps modeling the cook and delivery times. ## Fast Track 1. Create three new step files: `dispatch-delivery.ts`, `confirm-delivery.ts`, `send-review-request.ts`. 2. Update `processOrder` to call each one in sequence with `sleep` between them. 3. Place an order. Wait about a minute. Check your inbox for the review request. ## Hands-on exercise **1. Write the steps.** `workflows/steps/dispatch-delivery.ts`: ```ts title="workflows/steps/dispatch-delivery.ts" import { updateStatus } from "@/lib/orders-store"; import type { Order } from "@/lib/pizza"; export async function dispatchDelivery(order: Order): Promise { "use step"; await new Promise((resolve) => setTimeout(resolve, 200)); updateStatus(order.id, "out-for-delivery"); } ``` `workflows/steps/confirm-delivery.ts`: ```ts title="workflows/steps/confirm-delivery.ts" import { updateStatus } from "@/lib/orders-store"; import type { Order } from "@/lib/pizza"; export async function confirmDelivery(order: Order): Promise { "use step"; updateStatus(order.id, "delivered"); } ``` `workflows/steps/send-review-request.ts`: ```ts title="workflows/steps/send-review-request.ts" import { FatalError } from "workflow"; import { resend, FROM } from "@/lib/resend"; import { updateStatus } from "@/lib/orders-store"; import type { Order } from "@/lib/pizza"; export async function sendReviewRequest(order: Order): Promise { "use step"; const resp = await resend.emails.send({ from: FROM, to: [order.email], subject: `How was your ${order.pizza}?`, html: `

Hi ${order.customerName},

Your pizza has landed. Hit reply and tell us how it was.

Sal

`, }); if (resp.error) { throw new FatalError(`Resend failed: ${resp.error.message}`); } updateStatus(order.id, "review-sent"); } ``` Same pattern every time. Async function, directive, do the work, persist the status. The Resend step throws `FatalError` on a Resend error, same logic as the confirmation step. **2. Update the workflow.** `workflows/process-order.ts`: ```ts title="workflows/process-order.ts" {5-7,15-18} import { sleep } from "workflow"; import type { Order } from "@/lib/pizza"; import { sendOrderConfirmation } from "./steps/send-order-confirmation"; import { acknowledgeKitchen } from "./steps/acknowledge-kitchen"; import { dispatchDelivery } from "./steps/dispatch-delivery"; import { confirmDelivery } from "./steps/confirm-delivery"; import { sendReviewRequest } from "./steps/send-review-request"; export async function processOrder(order: Order): Promise<{ orderId: string }> { "use workflow"; await sendOrderConfirmation(order); await acknowledgeKitchen(order); await sleep("30s"); await dispatchDelivery(order); await sleep("30s"); await confirmDelivery(order); await sendReviewRequest(order); return { orderId: order.id }; } ``` A minute of total sleep (`30s` + `30s`), two more emails, a final status of `delivered` then `review-sent`. The workflow now describes the entire happy path of an order. ## Try It Place an order on your production URL. Sit with it. The page polls every couple of seconds, so the status text will tick through the states: ``` 0:00 Order placed 0:01 Confirmation email sent 0:02 In the kitchen 0:32 Out for delivery 1:02 Delivered 1:03 Review request sent ``` You should get two emails total: the confirmation right after placing, and the review request about a minute later. Open the Workflows dashboard and click into the run. The timeline now shows the full sequence: ``` processOrder completed 62.4s ├─ sendOrderConfirmation completed 389ms ├─ acknowledgeKitchen completed 208ms ├─ (sleep 30s) ├─ dispatchDelivery completed 213ms ├─ (sleep 30s) ├─ confirmDelivery completed 78ms └─ sendReviewRequest completed 341ms ``` That's six steps and two sleeps, suspended and resumed twice, no infrastructure managed by you. \*\*Warning: The sleeps are temporary\*\* Real ordering systems don't pretend cook time is 30 seconds. The whole reason hooks exist is so the workflow can pause until the kitchen actually says it's done. In 3.1 we replace these sleeps with hooks. Don't get attached to them. ## Commit ``` feat(workflow): complete happy path with delivery and review ``` ## Done-When - [ ] Three new step files exist: `dispatch-delivery.ts`, `confirm-delivery.ts`, `send-review-request.ts` - [ ] `processOrder` calls all five steps in order with sleeps between the cook and delivery phases - [ ] Placing an order eventually triggers both the confirmation email and the review email - [ ] The dashboard timeline shows the full sequence ending in `delivered` then `review-sent` ## Solution `workflows/process-order.ts`: ```ts title="workflows/process-order.ts" import { sleep } from "workflow"; import type { Order } from "@/lib/pizza"; import { sendOrderConfirmation } from "./steps/send-order-confirmation"; import { acknowledgeKitchen } from "./steps/acknowledge-kitchen"; import { dispatchDelivery } from "./steps/dispatch-delivery"; import { confirmDelivery } from "./steps/confirm-delivery"; import { sendReviewRequest } from "./steps/send-review-request"; export async function processOrder(order: Order): Promise<{ orderId: string }> { "use workflow"; await sendOrderConfirmation(order); await acknowledgeKitchen(order); await sleep("30s"); await dispatchDelivery(order); await sleep("30s"); await confirmDelivery(order); await sendReviewRequest(order); return { orderId: order.id }; } ``` The full step files appear in the hands-on section above. Read them once, paste them once, and let the workflow narrate the rest. Next section we replace the sleeps with hooks that wait for the actual kitchen and driver to ping us. --- title: "Wait on a hook" description: "Replace the fake cook-time sleep with createHook(). Build a Route Handler that uses resumeHook() to wake the workflow when the kitchen marks an order ready." canonical_url: "https://vercel.com/academy/workflow-foundations/pause-until-the-kitchen-pings-us" md_url: "https://vercel.com/academy/workflow-foundations/pause-until-the-kitchen-pings-us.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-06-05T22:31:16.174Z" content_type: "lesson" course: "workflow-foundations" course_title: "Workflow Foundations" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Wait on a hook # Pause until the kitchen pings us Pizzas don't cook on a timer. They're ready when the kitchen says they're ready. The `sleep("30s")` we added in 2.2 is a lie we agreed to until we had something better. A real pizza shop has no idea when a pizza will be ready. It might be three minutes. It might be twenty. The workflow needs to wait until someone in the kitchen actually says, "this one's done." That's what hooks are for. A hook is a suspension point with a unique token. The workflow awaits the hook and goes to sleep. Some external request, like a button in the kitchen ops UI, calls `resumeHook(token, payload)` and the workflow wakes up with that payload as the value of the await. Three pieces: the hook in the workflow, the token that ties them together, and the Route Handler that fires the wakeup. ## Outcome Replace the post-kitchen `sleep("30s")` with a hook the workflow waits on. Build `/api/kitchen/ready` so the existing kitchen ops UI can wake up the workflow when a pizza is ready. ## Fast Track 1. In `processOrder`, replace `await sleep("30s")` after `acknowledgeKitchen` with a `createHook` waiting on `kitchen:{order.id}`. 2. Create `app/api/kitchen/ready/route.ts` that calls `resumeHook(\`kitchen:$\`, )\`. 3. Place an order. Open `/kitchen`. Click **Mark ready**. Watch the workflow continue. ## Hands-on exercise **1. Add the hook to the workflow.** `workflows/process-order.ts`: ```ts title="workflows/process-order.ts" {1,12-15} import { createHook } from "workflow"; import type { Order } from "@/lib/pizza"; import { sendOrderConfirmation } from "./steps/send-order-confirmation"; import { acknowledgeKitchen } from "./steps/acknowledge-kitchen"; import { dispatchDelivery } from "./steps/dispatch-delivery"; import { confirmDelivery } from "./steps/confirm-delivery"; import { sendReviewRequest } from "./steps/send-review-request"; export async function processOrder(order: Order): Promise<{ orderId: string }> { "use workflow"; await sendOrderConfirmation(order); await acknowledgeKitchen(order); using kitchenHook = createHook<{ readyAt: string }>({ token: `kitchen:${order.id}`, }); await kitchenHook; await dispatchDelivery(order); await sleep("30s"); await confirmDelivery(order); await sendReviewRequest(order); return { orderId: order.id }; } ``` Wait. There's something new here. `using` is a TC39 syntax for automatic resource cleanup. When the workflow scope ends, the hook gets disposed automatically, so you don't have stale tokens floating around. You'll see it again every time we create a hook. `createHook({ token })` returns a hook object. The generic `T` is the payload type. We're saying the kitchen will send us a `{ readyAt: string }` when it wakes us up. `{ token: \`kitchen:$\` }`is a custom token. Default tokens are random, but we want a deterministic one so the kitchen ops UI can construct it from data it already has (the order ID). Namespacing it with`kitchen:\` keeps tokens from colliding with the driver hooks we add in 3.2. `await kitchenHook` suspends the workflow. Compute stops. The workflow waits, possibly for hours, until someone resumes the hook with that token. **2. Build the resume endpoint.** Create `app/api/kitchen/ready/route.ts`: ```ts title="app/api/kitchen/ready/route.ts" import { resumeHook } from "workflow/api"; import { NextResponse } from "next/server"; export async function POST(request: Request) { const { orderId } = (await request.json()) as { orderId: string }; await resumeHook(`kitchen:${orderId}`, { readyAt: new Date().toISOString(), }); return NextResponse.json({ ok: true }); } ``` `resumeHook` takes the same token the workflow used and the payload to deliver. The runtime looks up the suspended workflow waiting on that token and wakes it up with the payload. The `await` on the workflow side resolves with what we passed here. The kitchen ops UI in the starter already POSTs to this exact endpoint. We're making the endpoint exist. ## Try It Place an order. The status page should tick to "In the kitchen" and stay there. In a new tab, open `/kitchen`. Your order is sitting in the oven with a **Mark ready** button. Click it. Two things happen in quick succession. The kitchen ops UI removes the order from the list. The customer's order page advances to "Out for delivery." In the dashboard, look at the run timeline: ``` processOrder running ├─ sendOrderConfirmation completed 389ms ├─ acknowledgeKitchen completed 208ms ├─ kitchen hook (kitchen:f8c2…) │ suspended 3m 22s │ resumed with { readyAt: "2026-..." } ├─ dispatchDelivery completed 213ms └─ (sleep 30s) ``` The hook line shows how long the workflow was suspended (minutes, hours, doesn't matter) and the payload it woke up with. \*\*Note: Why use a custom token\*\* We could have let `createHook` generate a random token, but then the kitchen ops UI would need to know that token to call `resumeHook`. With a deterministic token like `kitchen:{order.id}`, the UI just needs the order ID, which it already has from the order list. No extra round-trip to fetch a token. ## Commit ``` feat(workflow): wait on a kitchen hook instead of fake sleep ``` ## Done-When - [ ] `processOrder` creates a hook with token `kitchen:{order.id}` after `acknowledgeKitchen` - [ ] The workflow awaits the hook before `dispatchDelivery` - [ ] `app/api/kitchen/ready/route.ts` calls `resumeHook` with the same token - [ ] Clicking **Mark ready** in `/kitchen` wakes the workflow and the order moves to "Out for delivery" ## Solution `workflows/process-order.ts`: ```ts title="workflows/process-order.ts" import { createHook, sleep } from "workflow"; import type { Order } from "@/lib/pizza"; import { sendOrderConfirmation } from "./steps/send-order-confirmation"; import { acknowledgeKitchen } from "./steps/acknowledge-kitchen"; import { dispatchDelivery } from "./steps/dispatch-delivery"; import { confirmDelivery } from "./steps/confirm-delivery"; import { sendReviewRequest } from "./steps/send-review-request"; export async function processOrder(order: Order): Promise<{ orderId: string }> { "use workflow"; await sendOrderConfirmation(order); await acknowledgeKitchen(order); using kitchenHook = createHook<{ readyAt: string }>({ token: `kitchen:${order.id}`, }); await kitchenHook; await dispatchDelivery(order); await sleep("30s"); await confirmDelivery(order); await sendReviewRequest(order); return { orderId: order.id }; } ``` `app/api/kitchen/ready/route.ts`: ```ts title="app/api/kitchen/ready/route.ts" import { resumeHook } from "workflow/api"; import { NextResponse } from "next/server"; export async function POST(request: Request) { const { orderId } = (await request.json()) as { orderId: string }; await resumeHook(`kitchen:${orderId}`, { readyAt: new Date().toISOString(), }); return NextResponse.json({ ok: true }); } ``` One hook, one Route Handler. The workflow can now wait for arbitrarily long without a fake timer. We do the same trick for the driver next. --- title: "Hook the driver" description: "Replace the remaining sleep with two hooks (pickup, delivered) and build the corresponding Route Handlers so the driver UI drives the workflow." canonical_url: "https://vercel.com/academy/workflow-foundations/hook-the-driver-into-the-flow" md_url: "https://vercel.com/academy/workflow-foundations/hook-the-driver-into-the-flow.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-06-05T22:31:16.197Z" content_type: "lesson" course: "workflow-foundations" course_title: "Workflow Foundations" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Hook the driver # Hook the driver into the flow The kitchen pings us when the pizza's ready. The driver should do the same when they pick it up, and again when they hand it to Marge. Same pattern, twice. Two hooks, two Route Handlers. The shape of the code is now familiar, and that's the point. Hooks are how a workflow talks to anything outside itself. We also need a step in between so the order goes from "ready-for-pickup" to "out-for-delivery" when the driver actually grabs it. The driver UI in the starter has been listing both states this whole time. Now they'll actually flow through. ## Outcome Replace the post-dispatch `sleep` with two hooks: one for pickup, one for delivery. Build the two corresponding Route Handlers. The driver ops UI now drives the workflow through pickup and delivery. ## Fast Track 1. Add a `markOutForDelivery` step that sets status to `out-for-delivery`. 2. Update `processOrder` to swap the post-dispatch sleep for a `driver-pickup:{order.id}` hook, then run `markOutForDelivery`, then await a `driver-delivered:{order.id}` hook. 3. Build `/api/driver/pickup` and `/api/driver/delivered` to resume the matching hooks. ## Hands-on exercise **1. Add the intermediate step.** Right now `dispatchDelivery` sets status to `out-for-delivery`. That's a lie. The driver hasn't picked the pizza up yet. Let's change `dispatchDelivery` to mean "ready for pickup" and add a separate step for "out for delivery." Update `workflows/steps/dispatch-delivery.ts`: ```ts title="workflows/steps/dispatch-delivery.ts" {7} import { updateStatus } from "@/lib/orders-store"; import type { Order } from "@/lib/pizza"; export async function dispatchDelivery(order: Order): Promise { "use step"; await new Promise((resolve) => setTimeout(resolve, 200)); updateStatus(order.id, "ready-for-pickup"); } ``` Create `workflows/steps/mark-out-for-delivery.ts`: ```ts title="workflows/steps/mark-out-for-delivery.ts" import { updateStatus } from "@/lib/orders-store"; import type { Order } from "@/lib/pizza"; export async function markOutForDelivery(order: Order): Promise { "use step"; updateStatus(order.id, "out-for-delivery"); } ``` **2. Wire two hooks into the workflow.** Update `workflows/process-order.ts`: ```ts title="workflows/process-order.ts" {7,21-31} import { createHook } from "workflow"; import type { Order } from "@/lib/pizza"; import { sendOrderConfirmation } from "./steps/send-order-confirmation"; import { acknowledgeKitchen } from "./steps/acknowledge-kitchen"; import { dispatchDelivery } from "./steps/dispatch-delivery"; import { markOutForDelivery } from "./steps/mark-out-for-delivery"; import { confirmDelivery } from "./steps/confirm-delivery"; import { sendReviewRequest } from "./steps/send-review-request"; export async function processOrder(order: Order): Promise<{ orderId: string }> { "use workflow"; await sendOrderConfirmation(order); await acknowledgeKitchen(order); using kitchenHook = createHook<{ readyAt: string }>({ token: `kitchen:${order.id}`, }); await kitchenHook; await dispatchDelivery(order); using pickupHook = createHook<{ pickedUpAt: string }>({ token: `driver-pickup:${order.id}`, }); await pickupHook; await markOutForDelivery(order); using deliveryHook = createHook<{ deliveredAt: string }>({ token: `driver-delivered:${order.id}`, }); await deliveryHook; await confirmDelivery(order); await sendReviewRequest(order); return { orderId: order.id }; } ``` Two new hooks. Notice the token namespacing: `driver-pickup:` and `driver-delivered:` so they don't collide with the kitchen's hook or each other. We don't import `sleep` anymore; nothing in the workflow is on a fake timer. **3. Build the resume endpoints.** `app/api/driver/pickup/route.ts`: ```ts title="app/api/driver/pickup/route.ts" import { resumeHook } from "workflow/api"; import { NextResponse } from "next/server"; export async function POST(request: Request) { const { orderId } = (await request.json()) as { orderId: string }; await resumeHook(`driver-pickup:${orderId}`, { pickedUpAt: new Date().toISOString(), }); return NextResponse.json({ ok: true }); } ``` `app/api/driver/delivered/route.ts`: ```ts title="app/api/driver/delivered/route.ts" import { resumeHook } from "workflow/api"; import { NextResponse } from "next/server"; export async function POST(request: Request) { const { orderId } = (await request.json()) as { orderId: string }; await resumeHook(`driver-delivered:${orderId}`, { deliveredAt: new Date().toISOString(), }); return NextResponse.json({ ok: true }); } ``` If you're squinting and thinking "these are almost identical," you're right. We could DRY them up. We won't. Two endpoints with three lines each that say what they do is clearer than one clever endpoint with a `state` param. ## Try It Place an order. Walk the order through all four humans now: 1. `/kitchen` → click **Mark ready**. Status moves to "Ready for pickup." 2. `/driver` → first list shows the order. Click **Pick up**. Status moves to "Out for delivery." 3. `/driver` → second list shows the order. Click **Mark delivered**. Status moves to "Delivered." 4. Inbox → the review email arrives a moment later. The customer's `/orders/[runId]` page polls and updates as you click. So does the kitchen and driver ops UIs. In the dashboard timeline: ``` processOrder completed ├─ sendOrderConfirmation completed ├─ acknowledgeKitchen completed ├─ kitchen hook resumed ├─ dispatchDelivery completed ├─ driver-pickup hook resumed ├─ markOutForDelivery completed ├─ driver-delivered hook resumed ├─ confirmDelivery completed └─ sendReviewRequest completed ``` Every step. Every hook. Every resume. Every payload. All of it persisted by Vercel. \*\*Note: A workflow is glue\*\* We've now written six steps and three hooks. The workflow itself is almost entirely `await` statements. That's the right amount of code for a workflow to have. The actual work lives in steps. The actual events live outside. The workflow's job is to sequence them. ## Commit ``` feat(workflow): drive pickup and delivery with hooks ``` ## Done-When - [ ] `dispatchDelivery` now sets status to `ready-for-pickup` - [ ] A new `markOutForDelivery` step sets status to `out-for-delivery` - [ ] `processOrder` awaits the pickup hook, runs `markOutForDelivery`, then awaits the delivery hook - [ ] `/api/driver/pickup` and `/api/driver/delivered` exist and use `resumeHook` - [ ] The driver ops UI walks an order from ready-for-pickup to delivered ## Solution The new step files appear in the hands-on section above. The complete workflow: ```ts title="workflows/process-order.ts" import { createHook } from "workflow"; import type { Order } from "@/lib/pizza"; import { sendOrderConfirmation } from "./steps/send-order-confirmation"; import { acknowledgeKitchen } from "./steps/acknowledge-kitchen"; import { dispatchDelivery } from "./steps/dispatch-delivery"; import { markOutForDelivery } from "./steps/mark-out-for-delivery"; import { confirmDelivery } from "./steps/confirm-delivery"; import { sendReviewRequest } from "./steps/send-review-request"; export async function processOrder(order: Order): Promise<{ orderId: string }> { "use workflow"; await sendOrderConfirmation(order); await acknowledgeKitchen(order); using kitchenHook = createHook<{ readyAt: string }>({ token: `kitchen:${order.id}`, }); await kitchenHook; await dispatchDelivery(order); using pickupHook = createHook<{ pickedUpAt: string }>({ token: `driver-pickup:${order.id}`, }); await pickupHook; await markOutForDelivery(order); using deliveryHook = createHook<{ deliveredAt: string }>({ token: `driver-delivered:${order.id}`, }); await deliveryHook; await confirmDelivery(order); await sendReviewRequest(order); return { orderId: order.id }; } ``` Everything in this workflow is now event-driven. There's just one problem: if the kitchen ghosts us, the workflow waits forever. That's 3.3. --- title: "When nobody pings" description: "Use Promise.race with sleep to add a timeout to the kitchen hook, escalating with an email when the kitchen ghosts the workflow." canonical_url: "https://vercel.com/academy/workflow-foundations/what-if-the-kitchen-never-responds" md_url: "https://vercel.com/academy/workflow-foundations/what-if-the-kitchen-never-responds.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-06-05T22:31:16.219Z" content_type: "lesson" course: "workflow-foundations" course_title: "Workflow Foundations" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # When nobody pings # What if the kitchen never responds? The kitchen forgets. The printer jams. Sal is taking a nap and the order ticket lands on the floor behind the oven. Whatever the reason, a real ordering system has to assume the kitchen sometimes doesn't ping back. Right now, the workflow waits forever. That's not "durable." That's just stuck. The fix is two primitives we already have, awkwardly close to each other: a hook and a `sleep`. We race them with `Promise.race`. Whichever resolves first wins. If the hook wins, the kitchen pinged us; we keep going. If the sleep wins, the kitchen didn't ping us; we escalate. This is the only "pattern" lesson in the course. Everything else is "use the primitive." This one combines primitives. Worth knowing. ## Outcome Add a 20-minute timeout to the kitchen hook. If the kitchen doesn't ping in time, send Marge an escalation email and stop the workflow. ## Fast Track 1. Add a `sendEscalationEmail` step that sends an "we're checking on your order" email and sets status to `escalated`. 2. Update `processOrder` to `Promise.race` the kitchen hook against `sleep("20m")` with tagged results. 3. If the race resolves to `"timeout"`, call `sendEscalationEmail` and return early. Otherwise, continue as before. ## Hands-on exercise **1. Write the escalation step.** `workflows/steps/send-escalation-email.ts`: ```ts title="workflows/steps/send-escalation-email.ts" import { FatalError } from "workflow"; import { resend, FROM } from "@/lib/resend"; import { updateStatus } from "@/lib/orders-store"; import type { Order } from "@/lib/pizza"; export async function sendEscalationEmail(order: Order): Promise { "use step"; const resp = await resend.emails.send({ from: FROM, to: [order.email], subject: "We're checking on your order", html: `

Hi ${order.customerName},

Your ${order.pizza} is taking longer than usual. We're calling the kitchen now.

Sal

`, }); if (resp.error) { throw new FatalError(`Resend failed: ${resp.error.message}`); } updateStatus(order.id, "escalated"); } ``` **2. Race the hook against sleep.** Update `workflows/process-order.ts`. Only the section between the kitchen ack and the dispatch changes: ```ts title="workflows/process-order.ts" {1,8-9,18-36} import { createHook, sleep } from "workflow"; import type { Order } from "@/lib/pizza"; import { sendOrderConfirmation } from "./steps/send-order-confirmation"; import { acknowledgeKitchen } from "./steps/acknowledge-kitchen"; import { dispatchDelivery } from "./steps/dispatch-delivery"; import { markOutForDelivery } from "./steps/mark-out-for-delivery"; import { confirmDelivery } from "./steps/confirm-delivery"; import { sendReviewRequest } from "./steps/send-review-request"; import { sendEscalationEmail } from "./steps/send-escalation-email"; const KITCHEN_TIMEOUT = "20m"; export async function processOrder(order: Order): Promise<{ status: "delivered" | "escalated"; orderId: string; }> { "use workflow"; await sendOrderConfirmation(order); await acknowledgeKitchen(order); using kitchenHook = createHook<{ readyAt: string }>({ token: `kitchen:${order.id}`, }); const kitchenResult = await Promise.race([ kitchenHook.then((payload) => ({ kind: "ready" as const, payload })), sleep(KITCHEN_TIMEOUT).then(() => ({ kind: "timeout" as const })), ]); if (kitchenResult.kind === "timeout") { await sendEscalationEmail(order); return { status: "escalated", orderId: order.id }; } await dispatchDelivery(order); using pickupHook = createHook<{ pickedUpAt: string }>({ token: `driver-pickup:${order.id}`, }); await pickupHook; await markOutForDelivery(order); using deliveryHook = createHook<{ deliveredAt: string }>({ token: `driver-delivered:${order.id}`, }); await deliveryHook; await confirmDelivery(order); await sendReviewRequest(order); return { status: "delivered", orderId: order.id }; } ``` Two things to call out. The `.then(...)` calls tag each promise's resolution with a `kind` discriminator. That's what makes the result type narrow correctly in the `if` block. Without the tags, the result is just `unknown | undefined` and you have to squint to tell which side won. `Promise.race` is the documented way to add a timeout to a hook in the Workflow SDK. The runtime treats `sleep` and the hook as ordinary promises here. Whichever resolves first, that's the value of the `await`. The whole pattern is composable in a way that built-in timeouts wouldn't be. The workflow's return type now includes both outcomes: `"delivered"` and `"escalated"`. The caller of `start(processOrder, [order])` can `await run.returnValue` later and react appropriately. ## Try It Drop the timeout to something short while you're testing. Change `KITCHEN_TIMEOUT` to `"30s"` so you don't have to wait 20 minutes. Deploy that change, then place an order. Don't touch the kitchen ops UI. Just wait. 30 seconds in, the run advances past the hook with `kind: "timeout"`. The escalation email arrives. The status moves to "Order escalated." The workflow ends with `{ status: "escalated", orderId }`. In the dashboard: ``` processOrder completed 42.1s ├─ sendOrderConfirmation completed 389ms ├─ acknowledgeKitchen completed 208ms ├─ kitchen hook timed out (30s) └─ sendEscalationEmail completed 341ms ``` Now place a second order. This time, click **Mark ready** in the kitchen UI within 30 seconds. The race resolves to `"ready"`. The escalation email is never sent. The workflow continues to dispatch, pickup, delivery, review. Change `KITCHEN_TIMEOUT` back to `"20m"` before shipping anything you care about. \*\*Note: The Promise.race trick generalizes\*\* This is the same pattern you'd use to time out any waiting workflow step. Race the thing you're waiting on against `sleep(...)`. Tag both sides so the result type narrows. Branch on the tag. It works for hooks, webhooks, even a slow third-party API wrapped in a step. ## Commit ``` feat(workflow): escalate when the kitchen doesn't respond ``` ## Done-When - [ ] `workflows/steps/send-escalation-email.ts` exists with the `"use step"` directive - [ ] `processOrder` races the kitchen hook against `sleep(KITCHEN_TIMEOUT)` using tagged Promises - [ ] The timeout branch calls `sendEscalationEmail` and returns early - [ ] The workflow return type is `{ status: "delivered" | "escalated", orderId: string }` - [ ] You've tested both branches: one timeout, one normal completion ## Solution The escalation step appears in the hands-on section above. The complete workflow: ```ts title="workflows/process-order.ts" import { createHook, sleep } from "workflow"; import type { Order } from "@/lib/pizza"; import { sendOrderConfirmation } from "./steps/send-order-confirmation"; import { acknowledgeKitchen } from "./steps/acknowledge-kitchen"; import { dispatchDelivery } from "./steps/dispatch-delivery"; import { markOutForDelivery } from "./steps/mark-out-for-delivery"; import { confirmDelivery } from "./steps/confirm-delivery"; import { sendReviewRequest } from "./steps/send-review-request"; import { sendEscalationEmail } from "./steps/send-escalation-email"; const KITCHEN_TIMEOUT = "20m"; export async function processOrder(order: Order): Promise<{ status: "delivered" | "escalated"; orderId: string; }> { "use workflow"; await sendOrderConfirmation(order); await acknowledgeKitchen(order); using kitchenHook = createHook<{ readyAt: string }>({ token: `kitchen:${order.id}`, }); const kitchenResult = await Promise.race([ kitchenHook.then((payload) => ({ kind: "ready" as const, payload })), sleep(KITCHEN_TIMEOUT).then(() => ({ kind: "timeout" as const })), ]); if (kitchenResult.kind === "timeout") { await sendEscalationEmail(order); return { status: "escalated", orderId: order.id }; } await dispatchDelivery(order); using pickupHook = createHook<{ pickedUpAt: string }>({ token: `driver-pickup:${order.id}`, }); await pickupHook; await markOutForDelivery(order); using deliveryHook = createHook<{ deliveredAt: string }>({ token: `driver-delivered:${order.id}`, }); await deliveryHook; await confirmDelivery(order); await sendReviewRequest(order); return { status: "delivered", orderId: order.id }; } ``` The workflow is now feature-complete on the happy path. Section 4 is where it stops pretending the unhappy path doesn't exist. --- title: "Retries for free" description: "Introduce flakiness to acknowledgeKitchen, customize maxRetries, and observe automatic retries with backoff in the dashboard." canonical_url: "https://vercel.com/academy/workflow-foundations/the-kitchen-is-flaky" md_url: "https://vercel.com/academy/workflow-foundations/the-kitchen-is-flaky.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-06-05T22:31:16.266Z" content_type: "lesson" course: "workflow-foundations" course_title: "Workflow Foundations" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Retries for free # The kitchen is flaky In every lesson up to now, the kitchen step has done what it said and returned cleanly. That's because we wrote it that way. Reality is less polite. Real services flake. Networks blip. APIs throw 503s for reasons their own engineers can't explain. The whole reason workflows are interesting is that they handle this without you writing a single line of retry code. We're going to make `acknowledgeKitchen` fail 30% of the time on purpose. Then place a few orders. The dashboard will show steps failing and getting retried. The customer will never notice. ## Outcome Make `acknowledgeKitchen` throw intermittently. Bump its `maxRetries` to 5. Watch the runtime retry the step automatically and the customer's order complete normally. ## Fast Track 1. In `workflows/steps/acknowledge-kitchen.ts`, add `if (Math.random() < 0.3) throw new Error(...)` before the status update. 2. Add `acknowledgeKitchen.maxRetries = 5` after the function definition. 3. Place several orders. Watch some steps fail and retry in the dashboard while the orders still complete. ## Hands-on exercise **1. Add flakiness.** `workflows/steps/acknowledge-kitchen.ts`: ```ts title="workflows/steps/acknowledge-kitchen.ts" {7-9,15} import { updateStatus } from "@/lib/orders-store"; import type { Order } from "@/lib/pizza"; export async function acknowledgeKitchen(order: Order): Promise { "use step"; if (Math.random() < 0.3) { throw new Error("Kitchen printer jammed. Retrying."); } await new Promise((resolve) => setTimeout(resolve, 200)); updateStatus(order.id, "in-the-kitchen"); } acknowledgeKitchen.maxRetries = 5; ``` Two changes. The `Math.random()` check throws an ordinary `Error` about 30% of the time. The runtime treats any uncaught exception as retryable, so the step gets re-enqueued automatically. `acknowledgeKitchen.maxRetries = 5` bumps the retry count from the default of 3 (4 total attempts) to 5 (6 total attempts). It's a property on the step function. Set it once, after the function is defined. \*\*Note: Why this works for retries\*\* Workflows record every step's inputs to the event log. When a step fails, the runtime re-enqueues it with the same inputs, automatically. There's no retry queue you maintain, no exponential-backoff library you import. The framing for steps is "this might be flaky, run it however many times it needs." \*\*Warning: Workflows are deterministic, except for stabilized primitives\*\* We use `Math.random()` inside a step, not inside the workflow function. That matters. Workflow code re-runs during replay; if you called `Math.random()` inside the workflow, you'd get a different number each replay and the workflow would behave inconsistently. The runtime stabilizes `Math.random` and `Date` inside workflows specifically to prevent this, but it's clearer to keep nondeterministic decisions in steps where they belong. ## Try It Place five or six orders. In the dashboard, some runs look like the ones you've seen. Others look like this: ``` processOrder running ├─ sendOrderConfirmation completed 389ms └─ acknowledgeKitchen running attempts: 1. failed "Kitchen printer jammed. Retrying." 2. completed 211ms ``` The step failed, the runtime waited briefly, the step ran again with the same inputs, the second attempt succeeded. The workflow keeps going. Marge never knew there was a problem. If you get really unlucky, you might see a step fail three times in a row before succeeding. Or get sketchy enough to fail all six attempts, at which point the workflow fails. With 30% flakiness and 6 attempts, the probability of all attempts failing is `0.3^6` ≈ 0.07%. Rare. But possible. Now drop `maxRetries` to 1 just to see it happen: ```ts acknowledgeKitchen.maxRetries = 1; ``` Place orders. About 30% of them now fail outright on the kitchen step. The workflow shows up red in the dashboard with the error attached. Customer experience: bad. You'd never ship this. But it makes the difference between 1 retry and 5 retries visceral. Set `maxRetries` back to 5 before moving on. ## Commit ``` feat(workflow): introduce kitchen flakiness with maxRetries ``` ## Done-When - [ ] `acknowledgeKitchen` throws an `Error` roughly 30% of the time - [ ] `acknowledgeKitchen.maxRetries = 5` is set after the function definition - [ ] You've watched a step fail and retry in the dashboard - [ ] You've toggled `maxRetries` between 1 and 5 to see the difference ## Solution ```ts title="workflows/steps/acknowledge-kitchen.ts" import { updateStatus } from "@/lib/orders-store"; import type { Order } from "@/lib/pizza"; export async function acknowledgeKitchen(order: Order): Promise { "use step"; if (Math.random() < 0.3) { throw new Error("Kitchen printer jammed. Retrying."); } await new Promise((resolve) => setTimeout(resolve, 200)); updateStatus(order.id, "in-the-kitchen"); } acknowledgeKitchen.maxRetries = 5; ``` Two lines added, retry behavior unlocked. We didn't write a backoff function. We didn't add a queue. We didn't track attempts in a database. The directive bought us all of it. --- title: "Final failures" description: "Introduce a chargeCard step at the front of the workflow that throws FatalError on a declined card, stopping retries cleanly." canonical_url: "https://vercel.com/academy/workflow-foundations/some-failures-are-final" md_url: "https://vercel.com/academy/workflow-foundations/some-failures-are-final.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-06-05T22:31:16.288Z" content_type: "lesson" course: "workflow-foundations" course_title: "Workflow Foundations" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Final failures # Some failures are final Retries are the default. That's mostly what you want. If the kitchen printer jammed once, it might work next time. If the Resend API hiccuped, give it another shot. But some things will never get better. A card that says "declined" will keep saying "declined." Burning four attempts before giving up isn't graceful. It's just slow. `FatalError` is the way you tell the runtime: this one is done. Don't retry. Mark the workflow as failed and move on. We've been using it in our Resend steps since 1.2 without dwelling on it. Today we add the step that exists specifically to fail when it should: `chargeCard`. ## Outcome Add a `chargeCard` step at the front of `processOrder`. If the card's last 4 is `0000`, throw a `FatalError`. The workflow fails immediately with no retry attempts. ## Fast Track 1. Create `workflows/steps/charge-card.ts` that throws `FatalError` when `cardLast4 === "0000"`. 2. Call `chargeCard` as the first step in `processOrder`. 3. Place an order with `0000` as the card last 4. Watch the workflow fail immediately with one attempt. ## Hands-on exercise **1. Write the step.** `workflows/steps/charge-card.ts`: ```ts title="workflows/steps/charge-card.ts" import { FatalError } from "workflow"; import { updateStatus } from "@/lib/orders-store"; import type { Order } from "@/lib/pizza"; export async function chargeCard(order: Order): Promise<{ chargedAt: string }> { "use step"; if (order.cardLast4 === "0000") { throw new FatalError(`Card declined for order ${order.id}`); } await new Promise((resolve) => setTimeout(resolve, 200)); updateStatus(order.id, "charged"); return { chargedAt: new Date().toISOString() }; } ``` The shape is familiar. The interesting bit is the conditional `FatalError`. In a real implementation, this is where you'd call Stripe and check the response. A `card_declined` error would throw `FatalError`. A network blip would throw a regular `Error` so the runtime retries. The distinction is intent: "this won't recover" versus "this might recover." Return values matter, too. The function returns `{ chargedAt }`. That gets persisted in the event log and would normally be useful downstream. We're not consuming it, but the runtime is still recording it. **2. Call it first in the workflow.** `workflows/process-order.ts` (only the first call changes): ```ts title="workflows/process-order.ts" {9,12} import { createHook, sleep } from "workflow"; import type { Order } from "@/lib/pizza"; import { chargeCard } from "./steps/charge-card"; import { sendOrderConfirmation } from "./steps/send-order-confirmation"; // ... other imports unchanged export async function processOrder(order: Order): Promise<{ status: "delivered" | "escalated"; orderId: string; }> { "use workflow"; await chargeCard(order); await sendOrderConfirmation(order); // ... rest unchanged } ``` One import, one `await` at the top. The rest of the workflow doesn't change. ## Try It Place an order with **card last 4** set to `4242`. Everything works the way it has all course. The workflow charges the card, confirms the order, and proceeds. Now place an order with **card last 4** set to `0000`. Look at the dashboard: ``` processOrder failed 213ms └─ chargeCard failed 208ms error: FatalError: Card declined for order f8c2... attempts: 1 ``` One attempt. Failed immediately. No retries. Swap the `FatalError` for a regular `Error` and try again: ```ts if (order.cardLast4 === "0000") { throw new Error(`Card declined for order ${order.id}`); } ``` Now the same order burns through `chargeCard.maxRetries + 1` attempts before giving up: ``` processOrder failed 1.4s └─ chargeCard failed attempts: 1. failed 2. failed 3. failed 4. failed ``` The customer waited an extra second for the same result. That's the cost of treating a "this won't ever work" as if it might. Put the `FatalError` back. \*\*Note: Two kinds of failure\*\* Steps that should retry: throw `Error` (or any subclass that isn't `FatalError`). Steps that should fail immediately: throw `FatalError`. There's no third category. Most steps are the first kind; a few payment, validation, or "not found" cases are the second. ## Commit ``` feat(workflow): add chargeCard step with FatalError on decline ``` ## Done-When - [ ] `workflows/steps/charge-card.ts` exists and throws `FatalError` when `cardLast4 === "0000"` - [ ] `processOrder` awaits `chargeCard` as its first step - [ ] Placing an order with `cardLast4: "0000"` fails the workflow with one attempt - [ ] Placing an order with `cardLast4: "4242"` continues through the normal flow ## Solution `workflows/steps/charge-card.ts`: ```ts title="workflows/steps/charge-card.ts" import { FatalError } from "workflow"; import { updateStatus } from "@/lib/orders-store"; import type { Order } from "@/lib/pizza"; export async function chargeCard(order: Order): Promise<{ chargedAt: string }> { "use step"; if (order.cardLast4 === "0000") { throw new FatalError(`Card declined for order ${order.id}`); } await new Promise((resolve) => setTimeout(resolve, 200)); updateStatus(order.id, "charged"); return { chargedAt: new Date().toISOString() }; } ``` `workflows/process-order.ts` (the only change is adding `chargeCard` as the first call): ```ts title="workflows/process-order.ts" import { createHook, sleep } from "workflow"; import type { Order } from "@/lib/pizza"; import { chargeCard } from "./steps/charge-card"; import { sendOrderConfirmation } from "./steps/send-order-confirmation"; import { acknowledgeKitchen } from "./steps/acknowledge-kitchen"; import { dispatchDelivery } from "./steps/dispatch-delivery"; import { markOutForDelivery } from "./steps/mark-out-for-delivery"; import { confirmDelivery } from "./steps/confirm-delivery"; import { sendReviewRequest } from "./steps/send-review-request"; import { sendEscalationEmail } from "./steps/send-escalation-email"; const KITCHEN_TIMEOUT = "20m"; export async function processOrder(order: Order): Promise<{ status: "delivered" | "escalated"; orderId: string; }> { "use workflow"; await chargeCard(order); await sendOrderConfirmation(order); await acknowledgeKitchen(order); using kitchenHook = createHook<{ readyAt: string }>({ token: `kitchen:${order.id}`, }); const kitchenResult = await Promise.race([ kitchenHook.then((payload) => ({ kind: "ready" as const, payload })), sleep(KITCHEN_TIMEOUT).then(() => ({ kind: "timeout" as const })), ]); if (kitchenResult.kind === "timeout") { await sendEscalationEmail(order); return { status: "escalated", orderId: order.id }; } await dispatchDelivery(order); using pickupHook = createHook<{ pickedUpAt: string }>({ token: `driver-pickup:${order.id}`, }); await pickupHook; await markOutForDelivery(order); using deliveryHook = createHook<{ deliveredAt: string }>({ token: `driver-delivered:${order.id}`, }); await deliveryHook; await confirmDelivery(order); await sendReviewRequest(order); return { status: "delivered", orderId: order.id }; } ``` `FatalError` and `Error` look the same in code. They behave very differently. Pick on purpose. --- title: "Observe everything" description: "Tour the Vercel Workflows dashboard, inspect every step's inputs and outputs, deploy a breaking change mid-workflow, and walk through how to debug production failures." canonical_url: "https://vercel.com/academy/workflow-foundations/observe-everything" md_url: "https://vercel.com/academy/workflow-foundations/observe-everything.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-06-05T22:31:16.310Z" content_type: "lesson" course: "workflow-foundations" course_title: "Workflow Foundations" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Observe everything # Observe everything You've been using the dashboard the entire course. Every time we placed an order, it logged what happened. Every time a step failed, it showed why. Every time the workflow waited, it told you what for. This is the lesson where we stop treating it as scenery and use it on purpose. Production workflows fail at 3am. The on-call engineer's first move is to open the dashboard, find the failing run, and figure out what step blew up and why. There's no SSHing into a box. There's no rummaging through logs. The dashboard is the debugger. No new code today. Just a tour. Then you ship. ## Outcome Walk through every view in the Workflows dashboard with intent. Know where to look when a run fails, where step inputs and outputs live, and how to confirm a workflow survived a deploy. ## Fast Track 1. Open your project's **Workflows** tab in Vercel. Find a successful run, a failed run, and a still-running run. 2. Click into each. Read the timeline, expand a step, read its inputs and outputs. 3. Place an order. While it's mid-hook, ship a code change. Confirm the existing run completes on the new build. ## Hands-on exercise **1. The run list.** The **Workflows** tab in your Vercel project lists every run. Each row has the workflow name, status, duration, and the time the run started. If you don't see a healthy mix of `completed`, `failed`, and `running` runs, generate some: - Place an order with `cardLast4: "4242"` and walk it through. → `completed` - Place one with `cardLast4: "0000"`. → `failed` - Place one and don't touch the kitchen UI. → `running` for 20 minutes, then `completed` (escalated) - Place one and click **Mark ready** halfway. → `running` until you continue clicking **2. The timeline view.** Click into a completed run. The timeline shows every step in order, every sleep, every hook. ``` processOrder completed 3m 12s ├─ chargeCard completed 217ms │ input: { id: "0a4f…", cardLast4: "4242", ... } │ output: { chargedAt: "2026-..." } │ attempts: 1 ├─ sendOrderConfirmation completed 389ms │ input: { id: "0a4f…", email: "marge@...", ... } │ output: undefined │ attempts: 1 ├─ acknowledgeKitchen completed 843ms │ attempts: │ 1. failed "Kitchen printer jammed. Retrying." │ 2. completed ├─ kitchen hook (kitchen:0a4f…) │ resumed after 2m 14s with { readyAt: "2026-..." } ├─ dispatchDelivery completed 213ms ├─ driver-pickup hook resumed after 35s ├─ markOutForDelivery completed 18ms ├─ driver-delivered hook resumed after 22s ├─ confirmDelivery completed 12ms └─ sendReviewRequest completed 341ms ``` The runtime preserves everything: the inputs to each step (the full `order` object), the outputs (whatever the step returned), the attempt history (every retry, with the error message), and the hook payloads (what the kitchen/driver sent). **3. Inspect a retry.** Find a run where `acknowledgeKitchen` retried. Expand the step. You'll see attempts listed: ``` acknowledgeKitchen completed 843ms attempts: 1. failed 208ms Error: Kitchen printer jammed. Retrying. 2. completed 211ms ``` Each attempt is a separate enqueue. The runtime records the failure, waits its backoff, and re-runs the step with the same inputs. The customer never noticed. **4. Inspect a fatal failure.** Find a run that failed because of the `0000` card. Expand `chargeCard`: ``` chargeCard failed 208ms attempts: 1. failed FatalError: Card declined for order 0a4f... ``` One attempt. `FatalError` told the runtime not to retry. The workflow stopped immediately. Compare this to a run where you tried Resend with a deliberately bad email earlier. Same shape. **5. Inspect a hook.** Find a run that's still suspended on a hook. The timeline ends partway through with the hook line: ``` processOrder running ├─ chargeCard completed ├─ sendOrderConfirmation completed ├─ acknowledgeKitchen completed └─ kitchen hook (kitchen:0a4f…) suspended 1m 47s ``` It shows the token, how long the hook has been suspended, and that it's still waiting. No payload yet. Click into the kitchen ops UI and **Mark ready**. Refresh the dashboard. The hook line now shows `resumed`, and the workflow moves forward. **6. Survive a deploy.** Place an order and let it suspend on the kitchen hook. Open `workflows/steps/send-order-confirmation.ts`. Change the email subject to add "v2" at the end. Commit. Push. Vercel deploys. Click **Mark ready** in the kitchen UI to wake the suspended workflow. The dashboard shows the workflow continuing on the new deploy. The `sendOrderConfirmation` step that already completed doesn't run again. The "v2" subject change applies only to future orders. ``` processOrder completed ├─ chargeCard completed (replayed from event log) ├─ sendOrderConfirmation completed (replayed from event log, "v2" not used) ├─ acknowledgeKitchen completed (replayed from event log) ├─ kitchen hook resumed (on new deploy) ├─ dispatchDelivery completed (executed on new deploy) ... ``` That's the durability promise. Mid-flight workflows survive deploys. Their original timeline is sacred. ## Try It Open a fresh tab and place ten orders. Use different combinations: - Some with `4242`, some with `0000` - Some you walk through the kitchen and driver UIs - Some you ghost the kitchen for the full timeout - Some you redeploy mid-suspension Then go to the Workflows tab and look at the run list. You should see a mix of completed, failed, and escalated runs. Pick three at random. For each, predict what the timeline will look like before you click in. Then click in and check. If you can do this exercise comfortably, you can debug a Workflow SDK production incident. \*\*Note: What you can do now\*\* You can write durable workflows that sequence steps, wait for real-world events with hooks, time out gracefully, retry on flakes, fail fast on unrecoverable errors, survive deploys, and observe every step in production. That's the entire vocabulary. ## Commit Nothing to commit. This lesson is exploration. ## Done-When - [ ] You've walked through completed, failed, and running runs in the dashboard - [ ] You've expanded a step and read its inputs, outputs, and attempt history - [ ] You've inspected a hook in `suspended` and `resumed` states - [ ] You've shipped a code change mid-workflow and watched the existing run finish on the new build ## Solution No code in this lesson. The skill is the lesson. You started this course with `setTimeout` and a database table. You're ending it with `await sleep("7 days")` and a dashboard. Same problem, different vocabulary, an order of magnitude less code. Go ship a workflow. --- title: "Creating an AI Summary App with Next.js" description: "Build an AI-powered summary application using Next.js App Router and the Vercel AI SDK. You'll implement text summarization, structured output, caching, and production-ready patterns." canonical_url: "https://vercel.com/academy/ai-summary-app-with-nextjs" md_url: "https://vercel.com/academy/ai-summary-app-with-nextjs.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-09-22T04:50:17.960Z" content_type: "course" lessons: 14 estimated_time: lesson_urls: - "https://vercel.com/academy/ai-summary-app-with-nextjs/modern-nextjs-setup.md" - "https://vercel.com/academy/ai-summary-app-with-nextjs/type-safe-data-layer.md" - "https://vercel.com/academy/ai-summary-app-with-nextjs/review-display-components.md" - "https://vercel.com/academy/ai-summary-app-with-nextjs/dynamic-routes-static-generation.md" - "https://vercel.com/academy/ai-summary-app-with-nextjs/deploy-the-app.md" - "https://vercel.com/academy/ai-summary-app-with-nextjs/ai-gateway-setup.md" - "https://vercel.com/academy/ai-summary-app-with-nextjs/first-ai-summary.md" - "https://vercel.com/academy/ai-summary-app-with-nextjs/prompt-engineering.md" - "https://vercel.com/academy/ai-summary-app-with-nextjs/streaming-summaries.md" - "https://vercel.com/academy/ai-summary-app-with-nextjs/structured-output.md" - "https://vercel.com/academy/ai-summary-app-with-nextjs/smart-caching.md" - "https://vercel.com/academy/ai-summary-app-with-nextjs/when-ai-goes-wrong.md" - "https://vercel.com/academy/ai-summary-app-with-nextjs/observability-monitoring.md" - "https://vercel.com/academy/ai-summary-app-with-nextjs/complete.md" --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Creating an AI Summary App with Next.js Have you ever bought something online and immediately regretted it? A sweater you loved but it's the right size for a doll? A vacation rental filled with clown art? A fishing trip where the guide doesn't know how to drive the boat? You could blame yourself for not reading the reviews closely, or you could build an AI-powered summary app to help you make better decisions. Let's build a production-ready, AI-powered summary app using Next.js App Router and the Vercel AI SDK. You'll summarize product reviews using AI, with proper error handling, caching, and deployment to Vercel. ## How this course teaches This is a hands-on, incrementally built course: - **Start with fundamentals** - Build a solid Next.js foundation before adding AI - **Layer in complexity** - Add AI features progressively from basic to advanced - **Use production patterns** - Server Components, Zod validation, smart caching - **Handle real scenarios** - Error states, fallbacks, cost optimization - **Deploy working code** - Every section produces deployable, production-ready features ## What you'll build and learn in this course This course is split into three sections: 1. **Foundations**: Build a modern Next.js application with type-safe data layers and dynamic routes 2. **AI SDK integration**: Add AI-powered summarization with structured output and caching 3. **Production readiness**: Handle AI failures gracefully, configure fallbacks, and understand costs Each section builds on the previous one, creating a complete production application. ## Prerequisites Before diving in, make sure you have: - JavaScript/TypeScript: Comfortable with modern JS syntax and TypeScript basics - React: Familiar with components, hooks, and state management - Git: [Version control system](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) for version control - Node.js: [Latest LTS version](https://nodejs.org/en/download) (v22 or later recommended) - pnpm: [Package manager](https://pnpm.io/installation) used throughout the course - Vercel account: [Create one for free](https://vercel.com/signup) for deployment ## Overview of the course Here are details about each course section: ### Section 1: Foundations Build your Next.js application foundation with: - **Modern Next.js Setup**: Configure Next.js 16 with App Router, TypeScript, and Tailwind CSS - **Type-Safe Data Layer**: Create a type-safe data layer for product reviews with proper TypeScript patterns - **Review Display Components**: Build reusable components to display reviews with ratings and content - **Dynamic Routes & Static Generation**: Implement dynamic routing with static generation for optimal performance You'll create a solid foundation that's ready for AI integration, with production-ready patterns for data handling and rendering. ### Section 2: AI SDK integration Integrate the AI SDK to add intelligent summarization features: - **AI Gateway Setup**: Configure Vercel AI Gateway with API keys and environment variables - **First AI Summary**: Generate AI-powered review summaries using `generateText` and display them in a Server Component - **Prompt Engineering**: Improve output quality with few-shot examples, tone guidance, and response cleanup - **Structured Output**: Use `generateObject` with Zod schemas to extract pros, cons, and key themes - **Smart Caching**: Implement the `"use cache"` directive to reduce API costs by 97% Each lesson builds incrementally, teaching production patterns for schema design, error handling, and cost optimization. ### Section 3: Production readiness Ship your AI features with confidence: - **When AI goes wrong**: Handle failures gracefully with fallback UI, leverage AI Gateway's automatic model fallbacks, and understand what breaks and why - **Observability and monitoring**: Set up structured logging, explore AI Gateway analytics, and configure alerts so you know when things break before users tell you You'll finish with a fully deployed application that handles failures gracefully, costs what you expect, and keeps working even when AI providers have bad days. Help your customers never buy the wrong thing again. Let's build it! --- title: "Modern Next.js Setup" description: "Create a modern Next.js 16 application from scratch with TypeScript, Tailwind CSS, and shadcn/ui components. Set up the project structure for building a product review application." canonical_url: "https://vercel.com/academy/ai-summary-app-with-nextjs/modern-nextjs-setup" md_url: "https://vercel.com/academy/ai-summary-app-with-nextjs/modern-nextjs-setup.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2025-12-11T16:46:26.179Z" content_type: "lesson" course: "ai-summary-app-with-nextjs" course_title: "Creating an AI Summary App with Next.js" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Modern Next.js Setup # Modern Next.js setup Next.js 16 brings major improvements: async request APIs, better caching primitives, and Turbopack for faster dev builds. Starting with the latest version and modern tooling sets you up for success. ## Outcome Create a fresh Next.js 16 app with TypeScript, Tailwind CSS, and shadcn/ui components installed and configured. ## Fast Track 1. Run `npx create-next-app@latest ai-review-summary` (Yes to TypeScript, Tailwind, App Router; No to `src/` directory) 2. Run `npx shadcn@latest init` then `npx shadcn@latest add card avatar separator` 3. Replace `app/page.tsx` with a simple "Product Reviews" heading and run `pnpm dev` ## Hands-on Exercise 1.1 Set up a modern Next.js development environment: **Requirements:** 1. Create a new Next.js 16 app with TypeScript and Tailwind CSS 2. Configure the App Router 3. Install shadcn/ui and add basic components (card, avatar, separator) 4. Create folder structure: `lib/`, `components/`, `app/` 5. Run the dev server and verify it works **Implementation hints:** - Use `pnpm` for faster installs - Choose App Router when prompted - Enable Turbopack for dev mode - shadcn/ui requires a few config steps (see solution) ## Step 1: Create Next.js App ```bash npx create-next-app@latest ai-review-summary ``` **Configuration prompts:** ``` ✔ Would you like to use the recommended Next.js defaults? … Yes ``` ```bash cd ai-review-summary ``` ## Step 2: Install shadcn/ui shadcn/ui provides beautifully designed components built with Radix UI and Tailwind. ```bash npx shadcn@latest init ``` **Configuration prompts:** ``` ✔ Preflight checks. ✔ Verifying framework. Found Next.js. ✔ Validating Tailwind CSS. ✔ Validating import alias. ✔ Which color would you like to use as the base color? › Neutral ``` This creates: - `lib/utils.ts` with `cn()` helper - Updates to `tailwind.config.ts` ## Step 3: Add Base Components Install the components we'll need for the review app: ```bash npx shadcn@latest add card avatar separator ``` This adds: - `components/ui/card.tsx` - Card container - `components/ui/avatar.tsx` - User avatars - `components/ui/separator.tsx` - Dividers ## Step 4: Clean Up Default Files Remove the default Next.js boilerplate by replacing the content of the files with the following: **Update `app/page.tsx`:** ```tsx title="app/page.tsx" export default function Home() { return (

Product Reviews

A modern Next.js app for displaying customer reviews

); } ``` **Update `app/layout.tsx`:** ```tsx title="app/layout.tsx" {2-3} export const metadata: Metadata = { title: "AI Review Summary", description: "Customer reviews powered by AI", }; ``` ## Step 5: Run the Dev Server ```bash pnpm dev ``` Expected output: ``` ▲ Next.js 16.0.8 (Turbopack) - Local: http://localhost:3000 - Network: http://192.168.1.5:3000 ✓ Starting... ✓ Ready in 1.2s ``` ## Try It 1. **Visit** 2. **You should see:** - "Product Reviews" heading - Clean Tailwind styling - No console errors 3. **Test hot reload:** - Change the heading text in `app/page.tsx` - Save the file - Page updates instantly (Turbopack) 4. **Verify shadcn/ui works:** - Add a Card component to the page: ```tsx title="app/page.tsx" import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; export default function Home() { return (
Product Reviews

A modern Next.js app for displaying customer reviews

); } ``` 5. **Verify the card displays** with proper styling ## Project Structure Your app should now look like this: ``` ai-review-summary/ ├── app/ │ ├── layout.tsx # Root layout with metadata │ ├── page.tsx # Homepage │ └── globals.css # Global styles (Tailwind) ├── components/ │ └── ui/ # shadcn/ui components │ ├── card.tsx │ ├── avatar.tsx │ └── separator.tsx ├── lib/ │ └── utils.ts # Helper functions ├── next.config.ts # Next.js configuration ├── tailwind.config.ts # Tailwind configuration ├── tsconfig.json # TypeScript configuration └── package.json # Dependencies ``` ## Understanding the Stack **Next.js 16:** - App Router for file-based routing - Server Components by default (better performance) - Turbopack for 10x faster dev builds - React 19 with new async capabilities **TypeScript:** - Type safety across the entire app - Better IDE autocomplete - Catch errors before runtime **Tailwind CSS:** - Utility-first CSS framework - No separate CSS files needed - Responsive design out of the box **shadcn/ui:** - Copy/paste components (not a package dependency) - Built on Radix UI primitives - Fully customizable with Tailwind ## Troubleshooting **`npx shadcn@latest init` fails with "Could not find a configuration file"** You're running the command from the wrong directory. Make sure you're in your project root (where `package.json` lives): ```bash cd ai-review-summary npx shadcn@latest init ``` ## Done-When - [ ] Next.js 16 app created with TypeScript and Tailwind - [ ] App Router configured (not Pages Router) - [ ] shadcn/ui installed with card, avatar, separator components - [ ] Dev server runs without errors - [ ] Basic folder structure in place ## What's Next Your development environment is ready. In the next lesson, you'll build a type-safe data layer with Zod schemas for products and reviews. This foundation will make the app reliable and easy to extend. *** **Sources:** - [Next.js 16 Documentation](https://nextjs.org/docs) - [shadcn/ui Documentation](https://ui.shadcn.com) - [Tailwind CSS](https://tailwindcss.com) --- title: "Type-Safe Data Layer" description: "Build a robust data layer using Zod for runtime validation and type inference. Define Product and Review schemas, create sample data, and implement type-safe data access functions." canonical_url: "https://vercel.com/academy/ai-summary-app-with-nextjs/type-safe-data-layer" md_url: "https://vercel.com/academy/ai-summary-app-with-nextjs/type-safe-data-layer.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2025-12-11T16:46:26.199Z" content_type: "lesson" course: "ai-summary-app-with-nextjs" course_title: "Creating an AI Summary App with Next.js" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Type-Safe Data Layer # Type-Safe data layer TypeScript gives you compile-time type safety, but it can't validate data at runtime. Zod provides both: runtime validation AND TypeScript types inferred from your schemas. This catches bugs early and makes your code reliable. ## Outcome Create a type-safe data layer with Zod schemas for products and reviews, populate it with sample data, and implement helper functions for data access. ## Fast Track 1. Run `pnpm add zod` and create `lib/types.ts` with `ReviewSchema` and `ProductSchema` 2. Create `lib/sample-data.ts` with 3 products, each having 3-4 reviews with varied ratings 3. Add `getProducts()` and `getProduct(slug)` functions, then update `app/page.tsx` to display product cards ## Hands-on Exercise 1.2 Build a type-safe data layer for product reviews: **Requirements:** 1. Install Zod for schema validation 2. Create schemas for Review and Product types 3. Add sample data for 3-5 products with multiple reviews each 4. Create helper functions: `getProducts()` and `getProduct(id)` 5. Export TypeScript types inferred from Zod schemas **Implementation hints:** - Use `z.object()` to define schemas - Use `z.infer` to generate TypeScript types - Include fields: product name, slug, description, reviews array - Each review needs: reviewer name, stars (1-5), text, date - Sample data should feel realistic (varied ratings, detailed reviews) ## Step 1: Install Zod ```bash pnpm add zod ``` Zod is a TypeScript-first schema validation library with zero dependencies. ## Step 2: Create Type Schemas Create `lib/types.ts`: ```typescript title="lib/types.ts" import { z } from "zod"; // Review schema export const ReviewSchema = z.object({ reviewer: z.string(), stars: z.number().min(1).max(5), review: z.string(), date: z.string(), // ISO date string }); // Product schema export const ProductSchema = z.object({ slug: z.string(), name: z.string(), description: z.string(), reviews: z.array(ReviewSchema), }); // Infer TypeScript types from schemas export type Review = z.infer; export type Product = z.infer; ``` **What this gives you:** - Runtime validation with `.parse()` or `.safeParse()` - Automatic TypeScript types - Compile-time AND runtime safety ## Step 3: Create Sample Data Create `lib/sample-data.ts`: ```typescript title="lib/sample-data.ts" import { Product, ProductSchema } from "./types"; export const sampleProductsReviews: Record = { mower: { slug: "mower", name: "Mower3000", description: "Autonomous robotic lawn mower with smart navigation", reviews: [ { reviewer: "John D.", stars: 4, review: "Great mower! Handles slopes well and is very quiet. Setup took about an hour, but once configured it works autonomously. Battery lasts about 90 minutes.", date: "2025-11-15T10:30:00Z", }, { reviewer: "Sarah M.", stars: 5, review: "Love this thing! My lawn has never looked better. It runs every day at 6am and I don't have to think about it. The app is easy to use and scheduling is straightforward.", date: "2025-11-20T14:22:00Z", }, { reviewer: "Mike R.", stars: 2, review: "Disappointed. I hate mowing the lawn, and this did not change that.", date: "2025-11-28T08:15:00Z", }, { reviewer: "Emily K.", stars: 4, review: "Really impressed with the cutting quality. It mulches the grass perfectly. Only downside is it can't handle thick weeds, but that's expected. Worth the price.", date: "2025-12-01T16:45:00Z", }, ], }, ecoBright: { slug: "ecoBright", name: "EcoBright LED Bulbs", description: "Energy-efficient smart LED bulbs with color temperature control", reviews: [ { reviewer: "Amanda L.", stars: 5, review: "These bulbs are fantastic! Added a lot of ambiance to the room.", date: "2025-11-10T09:20:00Z", }, { reviewer: "Carlos P.", stars: 3, review: "Decent bulbs for the price. Color temperature control works well, but I wish they were brighter at max setting. They do save energy compared to my old bulbs.", date: "2025-11-18T12:33:00Z", }, { reviewer: "Lisa T.", stars: 4, review: "Very happy with these. The scheduling feature is great—bulbs dim automatically at 9pm. App is intuitive. Lost one star because one bulb failed after 3 months.", date: "2025-11-25T18:10:00Z", }, ], }, aquaHeat: { slug: "aquaHeat", name: "AquaHeat Tankless Water Heater", description: "High-efficiency tankless water heater with digital temperature control", reviews: [ { reviewer: "Robert F.", stars: 5, review: "Incredible upgrade from our old tank heater. Endless hot water and our energy bill dropped by 30%. Installation was professional and took about 4 hours.", date: "2025-10-05T11:15:00Z", }, { reviewer: "Jenny W.", stars: 4, review: "Works great but required upgrading our gas line which added $800 to the cost. Once installed, it's been flawless. Water heats instantly and temperature is consistent.", date: "2025-10-20T15:40:00Z", }, { reviewer: "Tom H.", stars: 3, review: "Good product but overpriced. It works as advertised but the 'energy savings' haven't been as dramatic as claimed. Still, no more running out of hot water is nice.", date: "2025-11-12T07:55:00Z", }, { reviewer: "Maria S.", stars: 5, review: "Best home improvement we've made! Compact design freed up space in our utility room. The digital display is clear and adjusting temperature is easy. Highly recommend.", date: "2025-11-30T13:25:00Z", }, ], }, }; // Validate data at runtime Object.values(sampleProductsReviews).forEach((product) => { ProductSchema.parse(product); }); export const Products = Object.values(sampleProductsReviews); ``` **What this provides:** - 3 products with varied reviews - Realistic review content and ratings - Runtime validation (throws if data is malformed) ## Step 4: Create Helper Functions Add data access functions to `lib/sample-data.ts`: ```typescript title="lib/sample-data.ts" export const Products = Object.values(sampleProductsReviews); /** * Add beneath the Products export */ export function getProducts(): Product[] { return Products; } /** * Get a single product by slug * @throws Error if product not found */ export function getProduct(slug: string): Product { const product = sampleProductsReviews[slug]; if (!product) { throw new Error(`Product not found: ${slug}`); } return product; } ``` **Type safety benefits:** - `getProducts()` returns `Product[]` (fully typed) - `getProduct()` returns `Product` (throws if not found) - TypeScript autocomplete works everywhere ## Try It **Test in your app:** Update `app/page.tsx` to display products: ```tsx title="app/page.tsx" import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; import { getProducts } from "@/lib/sample-data"; export default function Home() { const products = getProducts(); return (

Product Reviews

{products.map((product) => ( {product.name}

{product.description}

{product.reviews.length} reviews

))}
); } ``` **Visit ** You should see: - 3 product cards - Product names and descriptions - Review counts **Test type safety:** Try accessing a non-existent field: ```typescript product.nonExistentField // TypeScript error! ``` Try passing wrong data: ```typescript const badReview = { stars: 10 }; // Will fail Zod validation (max is 5) ReviewSchema.parse(badReview); // Throws error ``` ## Understanding Zod Benefits **Without Zod (plain TypeScript):** ```typescript type Product = { name: string; reviews: Review[]; }; // Runtime: no validation // If API returns bad data, your app breaks ``` **With Zod:** ```typescript const ProductSchema = z.object({ name: z.string(), reviews: z.array(ReviewSchema), }); type Product = z.infer; // Runtime: validated with .parse() // Type errors caught at compile time AND runtime ``` **Key advantages:** 1. **Single source of truth** - Schema defines both validation and types 2. **Runtime safety** - Catches invalid data from APIs, forms, databases 3. **Better errors** - Zod errors are detailed and actionable 4. **No type drift** - Types automatically match validation rules ## Project Structure Your data layer is now: ``` lib/ ├── types.ts # Zod schemas + TypeScript types ├── sample-data.ts # Sample products with reviews └── utils.ts # Helper functions (from shadcn) ``` ## Done-When - [ ] Zod installed and schemas created - [ ] Product and Review types defined - [ ] Sample data with 3+ products and multiple reviews - [ ] Helper functions `getProducts()` and `getProduct()` implemented - [ ] Homepage displays product list - [ ] Full type safety with compile-time and runtime validation ## What's Next Your data layer is solid and type-safe. In the next lesson, you'll build UI components to display individual reviews with star ratings, avatars, and timestamps. These components will use the Product and Review types you just created. *** **Sources:** - [Zod Documentation](https://zod.dev) - [TypeScript Type Inference](https://www.typescriptlang.org/docs/handbook/type-inference.html) --- title: "Review Display Components" description: "Create reusable React components for displaying product reviews. Build a five-star rating component, format timestamps as relative time, and display reviewer avatars with fallbacks." canonical_url: "https://vercel.com/academy/ai-summary-app-with-nextjs/review-display-components" md_url: "https://vercel.com/academy/ai-summary-app-with-nextjs/review-display-components.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2025-12-11T16:46:26.221Z" content_type: "lesson" course: "ai-summary-app-with-nextjs" course_title: "Creating an AI Summary App with Next.js" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Review Display Components # Review display components Good UI components are reusable, type-safe, and handle edge cases. You'll build components that work across your app and gracefully handle missing data like avatars or malformed dates. ## Outcome Create Review and FiveStarRating components that display customer reviews with stars, avatars, timestamps, and proper styling. ## Fast Track 1. Run `pnpm add ms && pnpm add -D @types/ms`, create `components/five-star-rating.tsx` using lucide-react Star icons 2. Create `components/review.tsx` as a Client Component with Avatar, FiveStarRating, and relative time using `ms` 3. Create `components/reviews.tsx` to map reviews with Separators, wrap product cards in Links on homepage ## Hands-on Exercise 1.3 Build UI components for displaying product reviews: **Requirements:** 1. Create a `FiveStarRating` component that displays 1-5 filled stars 2. Create a `Review` component that shows reviewer avatar, name, rating, date, and review text 3. Format dates as relative time ("2 days ago", "3 weeks ago") 4. Use shadcn/ui Avatar component with fallback initials 5. Make all components type-safe with the Review type from Lesson 1.2 **Implementation hints:** - Star rating: Use lucide-react icons (Star, StarHalf) - Timestamps: Install and use the `ms` library for relative time - Avatars: Extract initials from reviewer name for fallback - The Review component should be a Client Component (uses Date.now()) - Use Separator component between reviews ## Step 1: Install Dependencies ```bash pnpm add ms pnpm add -D @types/ms ``` The `ms` library converts milliseconds to human-readable strings. ## Step 2: Create FiveStarRating Component Create `components/five-star-rating.tsx`: ```tsx title="components/five-star-rating.tsx" import { Star } from "lucide-react"; export function FiveStarRating({ rating }: { rating: number }) { return (
{Array.from({ length: 5 }).map((_, i) => ( ))}
); } ``` **What this does:** - Creates 5 star icons - Fills stars based on rating (1-5) - Uses Tailwind for colors (yellow for filled, gray for empty) ## Step 3: Create Review Component Create `components/review.tsx`: ```tsx title="components/review.tsx" "use client"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Review as ReviewType } from "@/lib/types"; import ms from "ms"; import { FiveStarRating } from "./five-star-rating"; export function Review({ review }: { review: ReviewType }) { const date = new Date(review.date); return (
{getInitials(review.reviewer)}

{review.reviewer}

{review.review}

); } function getInitials(name: string): string { return name .split(" ") .map((word) => word[0]) .join("") .toUpperCase() .slice(0, 2); } function timeAgo(date: Date, suffix = true): string { const now = Date.now(); const diff = now - date.getTime(); if (diff < 1000) { return "Just now"; } return `${ms(diff, { long: true })}${suffix ? " ago" : ""}`; } ``` **Key features:** - `"use client"` directive (needed for `Date.now()` and `suppressHydrationWarning`) - Avatar with fallback initials - Five-star rating display - Relative timestamp ("2 days ago") - Flexible layout with Flexbox **Why `suppressHydrationWarning`?** Server-rendered timestamps differ from client-rendered ones (server time vs client time). This prop tells React to expect mismatches on first render. ## Step 4: Create Reviews Container Component Create `components/reviews.tsx`: ```tsx title="components/reviews.tsx" import { Product } from "@/lib/types"; import { Review } from "./review"; import { Separator } from "./ui/separator"; export function Reviews({ product }: { product: Product }) { return (

Customer Reviews

{product.reviews.map((review, index) => (
{index < product.reviews.length - 1 && ( )}
))}
); } ``` **What this does:** - Maps over product reviews - Renders Review component for each - Adds Separator between reviews (but not after the last one) ## Step 5: Update Homepage Update `app/page.tsx` to display star ratings and link to individual products: ```tsx title="app/page.tsx" {1,3,8-12,24-27} import Link from "next/link"; import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; import { FiveStarRating } from "@/components/five-star-rating"; import { getProducts } from "@/lib/sample-data"; export default function Home() { const products = getProducts(); function averageRating(reviews: { stars: number }[]) { if (reviews.length === 0) return 0; return reviews.reduce((sum, r) => sum + r.stars, 0) / reviews.length; } return (

Product Reviews

{products.map((product) => ( {product.name}
{product.reviews.length} reviews

{product.description}

))}
); } ``` **Changes:** - Imported `FiveStarRating` component - Added `averageRating` helper function - Display star rating with review count in each card - Wrapped cards in `Link` component with hover effect - Links to `/{product.slug}` (we'll create these pages in the next lesson) ## Try It 1. **Visit the homepage** at 2. **You should see:** - 3 product cards with names and descriptions - Star ratings showing average rating for each product - Review count next to the stars - Hover effect on cards (border color change) 3. **Click a product card** — it will 404 for now (we'll create product pages in Lesson 1.4) ## Understanding Client Components **Why is Review a Client Component?** The `timeAgo` function uses `Date.now()`, which is a dynamic value that changes every millisecond. Next.js can't statically generate or cache this because it's time-dependent. ```tsx "use client"; // Required because of Date.now() function timeAgo(date: Date): string { const diff = Date.now() - date.getTime(); // Date.now() changes constantly // ... } ``` **Server vs Client Components:** | Feature | Server Component | Client Component | | ---------------------- | ---------------- | -------------------------- | | Can use `Date.now()` | ❌ No | ✅ Yes | | Can use React hooks | ❌ No | ✅ Yes | | Can use event handlers | ❌ No | ✅ Yes | | Bundle sent to client | ❌ No (smaller) | ✅ Yes (larger) | | Default in Next.js 16 | ✅ Yes | Opt-in with `"use client"` | **Best practice:** Use Server Components by default, Client Components only when needed. ## Component Architecture Your component tree: ``` Reviews (Server Component) └── Review (Client Component) ← Uses Date.now() ├── Avatar ├── FiveStarRating └── Timestamp ``` Only the Review component needs to be a Client Component. Everything else stays as Server Components for better performance. ## Done-When - [ ] FiveStarRating component displays 1-5 stars - [ ] Review component shows avatar, name, rating, date, and text - [ ] Timestamps format as relative time ("2 days ago") - [ ] Components are fully type-safe - [ ] Homepage shows star ratings with average for each product - [ ] Homepage links to product pages (ready for next lesson) ## What's Next Your review UI is built and ready to display. In the next lesson, you'll create dynamic routes for individual product pages using Next.js App Router. You'll use `generateStaticParams` to pre-render all product pages at build time. *** **Sources:** - [Next.js Client Components](https://nextjs.org/docs/app/building-your-application/rendering/client-components) - [shadcn/ui Avatar](https://ui.shadcn.com/docs/components/avatar) - [lucide-react Icons](https://lucide.dev) - [ms library](https://github.com/vercel/ms) --- title: "Dynamic Routes & Static Generation" description: "Use Next.js App Router dynamic routes to create individual product pages. Implement generateStaticParams for static generation at build time, ensuring fast page loads." canonical_url: "https://vercel.com/academy/ai-summary-app-with-nextjs/dynamic-routes-static-generation" md_url: "https://vercel.com/academy/ai-summary-app-with-nextjs/dynamic-routes-static-generation.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2025-12-11T16:46:26.241Z" content_type: "lesson" course: "ai-summary-app-with-nextjs" course_title: "Creating an AI Summary App with Next.js" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Dynamic Routes & Static Generation # Dynamic routes & static generation Dynamic routes let you create pages programmatically without manually creating files. With `generateStaticParams`, Next.js pre-renders all product pages at build time. Result: instant page loads with zero server computation. ## Outcome Create dynamic product pages at `/[productId]` that display all reviews using the components from Lesson 1.3. Pre-render all pages at build time. ## Fast Track 1. Create `app/[productId]/page.tsx` with `params: Promise<{ productId: string }>` and `await params` 2. Add `generateStaticParams()` returning `products.map(p => ({ productId: p.slug }))` 3. Use `getProduct(productId)` with try/catch and `notFound()`, render `` ## Hands-on Exercise 1.4 Build dynamic product pages with static generation: **Requirements:** 1. Create a dynamic route at `app/[productId]/page.tsx` 2. Fetch product data using `getProduct(slug)` from Lesson 1.2 3. Display product name, description, and reviews 4. Implement `generateStaticParams` to pre-render all 3 products 5. Handle 404s with Next.js `notFound()` **Implementation hints:** - Params are now a Promise in Next.js 15+ (use `await params`) - `generateStaticParams` returns array of objects with route parameters - Use the Reviews component from Lesson 1.3 - The page should be a Server Component (no "use client" needed) ## Understanding Dynamic Routes **File structure:** ``` app/ ├── page.tsx # Homepage (/) └── [productId]/ └── page.tsx # Dynamic route (/:productId) ``` **Routes created:** - `/` → `app/page.tsx` - `/mower` → `app/[productId]/page.tsx` (productId = "mower") - `/ecoBright` → `app/[productId]/page.tsx` (productId = "ecoBright") - `/aquaHeat` → `app/[productId]/page.tsx` (productId = "aquaHeat") One file generates infinite routes. ## Step 1: Create Dynamic Route Create `app/[productId]/page.tsx`: ```tsx title="app/[productId]/page.tsx" import { notFound } from "next/navigation"; import { getProduct, getProducts } from "@/lib/sample-data"; import { Reviews } from "@/components/reviews"; export default async function ProductPage({ params, }: { params: Promise<{ productId: string }>; }) { const { productId } = await params; let product; try { product = getProduct(productId); } catch { notFound(); } return (
{/* Product Header */}

{product.name}

{product.description}

{/* Reviews */}
); } ``` **Key features:** - `params` is a Promise (Next.js 15+ change) - `await params` to get route parameters - `try/catch` handles invalid product IDs - `notFound()` renders 404 page ## Step 2: Add Static Generation Add `generateStaticParams` to the same file: ```tsx title="app/[productId]/page.tsx" {34-40} import { notFound } from "next/navigation"; import { getProduct, getProducts } from "@/lib/sample-data"; import { Reviews } from "@/components/reviews"; export default async function ProductPage({ params, }: { params: Promise<{ productId: string }>; }) { const { productId } = await params; let product; try { product = getProduct(productId); } catch { notFound(); } return (

{product.name}

{product.description}

); } export function generateStaticParams() { const products = getProducts(); return products.map((product) => ({ productId: product.slug, })); } ``` **What `generateStaticParams` does:** - Runs at build time - Returns array of route parameters to pre-render - Next.js generates HTML for each route - Pages load instantly (no server rendering needed) ## Step 3: Add Metadata Add dynamic metadata for SEO: ```tsx title="app/[productId]/page.tsx" {1,42-61} import { Metadata } from "next"; import { notFound } from "next/navigation"; import { getProduct, getProducts } from "@/lib/sample-data"; import { Reviews } from "@/components/reviews"; export default async function ProductPage({ params, }: { params: Promise<{ productId: string }>; }) { const { productId } = await params; let product; try { product = getProduct(productId); } catch { notFound(); } return (

{product.name}

{product.description}

); } export function generateStaticParams() { const products = getProducts(); return products.map((product) => ({ productId: product.slug, })); } export async function generateMetadata({ params, }: { params: Promise<{ productId: string }>; }): Promise { const { productId } = await params; let product; try { product = getProduct(productId); } catch { return { title: "Product Not Found", }; } return { title: `${product.name} - Customer Reviews`, description: product.description, }; } ``` **Benefits:** - Dynamic page titles (`Mower3000 - Customer Reviews`) - SEO-friendly descriptions - Falls back gracefully for 404s ## Try It 1. **Visit the homepage** at 2. **Click on a product card** 3. **You should see:** - Product name as heading - Product description - All reviews displayed with ratings, avatars, timestamps - Proper layout with separators 4. **Test all products:** - - - 5. **Test 404 handling:** - Visit - Should show Next.js 404 page ## Understanding Static Generation **Build time:** ```bash pnpm build ``` Output shows: ``` Route (app) ┌ ○ / ├ ○ /_not-found └ ● /[productId] ├ /mower ├ /ecoBright └ /aquaHeat ○ (Static) prerendered as static content ● (SSG) prerendered as static HTML (uses generateStaticParams) ``` The `●` symbol means "SSG" - statically generated using `generateStaticParams`. All product pages are pre-rendered at build time. **Runtime:** When a user visits `/mower`, Next.js serves pre-built HTML instantly. No database queries, no API calls, no computation. ## Static Generation Benefits | Metric | Dynamic (SSR) | Static (SSG) | | ------------- | ---------------------------- | ------------------- | | Server CPU | High (renders every request) | Zero (pre-rendered) | | Response Time | \~100-500ms | \~10ms | | Scalability | Limited (server bottleneck) | Infinite (CDN) | | Cost | High (always computing) | Low (compute once) | **Best for:** - Product pages (content changes rarely) - Blog posts - Documentation - Marketing pages **Not ideal for:** - User dashboards (personalized) - Real-time data - Frequently changing content ## Dynamic Route Patterns **Single parameter:** ``` [productId] → /mower, /ecoBright, /aquaHeat ``` **Multiple parameters:** ``` [category]/[productId] → /electronics/mower, /appliances/aquaHeat ``` **Catch-all:** ``` [...slug] → /any/nested/path/works ``` **Optional catch-all:** ``` [[...slug]] → / and /any/path both work ``` ## Extra Credit: Custom Not Found Page Create `app/[productId]/not-found.tsx`. ```tsx title="app/[productId]/not-found.tsx" import Link from "next/link"; export default function NotFound() { return (

Product Not Found

The product you're looking for doesn't exist.

Back to Products
); } ``` Now invalid product URLs show a custom 404 with a link back home. ## Done-When - [ ] Dynamic route created at `app/[productId]/page.tsx` - [ ] Product pages display name, description, and reviews - [ ] `generateStaticParams` pre-renders all 3 products - [ ] 404 handling works for invalid product IDs - [ ] Dynamic metadata sets page titles - [ ] All product links from homepage work ## What's Next Your app is functionally complete with product listings, individual product pages, and reviews display. In the next lesson, you'll deploy this to Vercel and see static generation in action on a production CDN. *** **Sources:** - [Next.js Dynamic Routes](https://nextjs.org/docs/app/building-your-application/routing/dynamic-routes) - [generateStaticParams](https://nextjs.org/docs/app/api-reference/functions/generate-static-params) - [Static Site Generation](https://nextjs.org/docs/pages/building-your-application/rendering/static-site-generation) - [notFound function](https://nextjs.org/docs/app/api-reference/functions/not-found) --- title: "Deploy the App" description: "Deploy your Next.js app to Vercel with automatic builds and preview deployments. Connect your GitHub repository for continuous deployment and see your statically generated pages live on a global CDN." canonical_url: "https://vercel.com/academy/ai-summary-app-with-nextjs/deploy-the-app" md_url: "https://vercel.com/academy/ai-summary-app-with-nextjs/deploy-the-app.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2025-12-11T16:46:26.260Z" content_type: "lesson" course: "ai-summary-app-with-nextjs" course_title: "Creating an AI Summary App with Next.js" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Deploy the App # Deploy the App Vercel is built by the Next.js team and optimized for Next.js apps. Deployments are instant, scaling is automatic, and static pages are served from a global CDN. Plus, you get preview URLs for every git push. ## Outcome Deploy your product review app to Vercel with automatic deployments from GitHub. Verify that all pages load instantly thanks to static generation. ## Fast Track 1. Run `git init && git add -A && git commit -m "feat: complete foundations"` then push to a new GitHub repo 2. Go to vercel.com → Add New Project → Import your repo (auto-detects Next.js) 3. Click Deploy, verify build shows `●` (SSG) for product routes, test production URL ## Hands-on Exercise 1.5 Deploy your app to production: **Requirements:** 1. Push your project to a GitHub repository 2. Import the repository to Vercel 3. Configure the project (use default settings) 4. Verify deployment and test all routes 5. Check build output to confirm static generation **Implementation hints:** - Create a new GitHub repo (public or private) - Vercel auto-detects Next.js projects - No environment variables needed yet (we'll add AI Gateway in Section 2) - Product pages should show `●` (SSG) in build output - Test all 3 product pages and homepage ## Step 1: Initialize Git Repository If you haven't already: ```bash git init git add -A git commit -m "feat: complete foundations section with product reviews" ``` ## Step 2: Create GitHub Repository 1. Go to [github.com](https://github.com) and create a new repository 2. Name it `ai-review-summary` (or your preferred name) 3. Don't initialize with README, .gitignore, or license (we already have code) 4. Copy the remote URL ## Step 3: Push to GitHub ```bash git remote add origin https://github.com/YOUR_USERNAME/ai-review-summary.git git branch -M main git push -u origin main ``` Your code is now on GitHub! ## Step 4: Import to Vercel 1. Go to [vercel.com](https://vercel.com) and sign in (or create account) 2. Click **"Add New\..."** → **"Project"** 3. Import your GitHub repository 4. Vercel auto-detects Next.js configuration **Project settings:** - Framework Preset: **Next.js** (auto-detected) - Root Directory: `./` (leave default) - Build Command: `next build` (auto-detected) - Output Directory: `.next` (auto-detected) - Install Command: `pnpm install` (auto-detected) 5. Click **Deploy** ## Step 5: Watch the Build Vercel streams the build output live: ``` Running "pnpm install" ... Building... Route (app) ┌ ○ / ├ ○ /_not-found └ ● /[productId] ├ /mower ├ /ecoBright └ /aquaHeat ○ (Static) prerendered as static content ● (SSG) prerendered as static HTML (uses generateStaticParams) ✓ Build successful ``` **What to look for:** - All product routes show `●` (SSG - static generation with `generateStaticParams`) - Homepage shows `○` (pure static) - No `ƒ` symbols (dynamic/server-rendered) - we want pure static - Build completes in \~30-60 seconds ## Step 6: Verify Deployment Once deployed, Vercel shows: - **Production URL**: `https://your-project.vercel.app` - **Deployment status**: ✓ Ready - **Visit button**: Opens your live site **Test your deployed app:** 1. **Visit production URL** 2. **Click through all products:** - All pages load instantly (static generation working) - Star ratings, avatars, timestamps display correctly - Links work between pages 3. **Test 404 handling:** - Visit `https://your-project.vercel.app/invalid-product` - Should show 404 page 4. **Check page load times** (open DevTools → Network tab): - Homepage: \~50-100ms - Product pages: \~50-100ms - All static HTML, no server rendering ## Understanding Vercel Deployments **Every git push creates:** 1. **Preview deployment** - Unique URL for testing 2. **Automatic builds** - No manual steps 3. **Instant rollbacks** - One-click revert to previous version **Production vs Preview:** - `main` branch → **Production** (`your-project.vercel.app`) - Other branches → **Preview** (`your-project-git-feature.vercel.app`) ## Automatic Deployments **Make a change and push:** ```bash # Edit something (e.g., update a product description in lib/sample-data.ts) git add -A git commit -m "docs: update product descriptions" git push ``` Vercel automatically: 1. Detects the push 2. Builds a new version 3. Deploys to production 4. Sends you a notification **Check deployment history:** - Vercel dashboard → Your project → Deployments - See every commit with timestamps - One-click rollback to any version ## Static Generation on Vercel **How Vercel serves your app:** ``` User requests /mower ↓ Vercel Edge Network (CDN) ↓ Serves pre-built HTML from nearest location ↓ ~10-50ms response time ``` No server computation. No database queries. Just HTML from a CDN. **Global performance:** - Tokyo: \~30ms - London: \~25ms - New York: \~20ms - São Paulo: \~40ms Same fast performance worldwide. ## Build Output Explained **Symbols:** - `○` (Static) - Pure HTML, prerendered as static content - `●` (SSG) - Prerendered as static HTML using `generateStaticParams` - `◐` (Partial Prerender) - Static HTML with dynamic server-streamed content - `ƒ` (Dynamic) - Server-rendered on demand **Your build:** ``` Route (app) ┌ ○ / # Homepage (static) ├ ○ /_not-found # 404 page (static) └ ● /[productId] # Product pages (SSG) ├ /mower ├ /ecoBright └ /aquaHeat ``` All pages are static. Perfect for a review site. ## Analytics and Monitoring **Vercel provides:** - Real-time analytics (Visitors, Page Views, Top Pages) - Web Vitals (Core Web Vitals scores) - Deployment logs - Function logs (when we add API routes later) **Check your analytics:** 1. Vercel dashboard → Your project → Analytics 2. See real user traffic 3. Monitor performance metrics ## Done-When - [ ] Code pushed to GitHub - [ ] Project deployed to Vercel - [ ] All pages load and function correctly - [ ] Production URL accessible - [ ] Automatic deployments working ## What's Next Your product review app is live on a global CDN with instant page loads. Section 1 (Foundations) is complete! In Section 2, you'll add AI-powered review summaries using Vercel AI Gateway and the AI SDK. You'll generate summaries with Claude, improve them with prompt engineering, add streaming, and extract structured output. *** **Sources:** - [Vercel Documentation](https://vercel.com/docs) - [Deploying Next.js](https://nextjs.org/docs/deployment) - [Vercel CLI](https://vercel.com/docs/cli) - [Custom Domains](https://vercel.com/docs/concepts/projects/custom-domains) --- title: "AI Gateway Setup" description: "Set up Vercel AI Gateway for production-ready AI access. Create an API key, configure environment variables, and install the AI SDK to prepare for generating review summaries." canonical_url: "https://vercel.com/academy/ai-summary-app-with-nextjs/ai-gateway-setup" md_url: "https://vercel.com/academy/ai-summary-app-with-nextjs/ai-gateway-setup.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2025-12-11T16:46:26.300Z" content_type: "lesson" course: "ai-summary-app-with-nextjs" course_title: "Creating an AI Summary App with Next.js" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # AI Gateway Setup # AI Gateway setup AI Gateway sits between your app and AI providers (Anthropic, OpenAI, etc.). It handles authentication, rate limiting, retries, cost tracking, and model fallbacks—all the production concerns you don't want to build yourself. ## Outcome Configure AI Gateway with an API key and install the AI SDK so you're ready to generate AI summaries. ## Fast Track 1. Vercel Dashboard → AI Gateway → API Keys → Create Key named `ai-review-summary-key` 2. Add `AI_GATEWAY_API_KEY=gw_xxx` to Vercel env vars (all 3 environments) AND `.env.local` 3. Run `pnpm add ai`, push to trigger redeploy with new env vars ## Hands-on Exercise 2.1 Set up AI Gateway for your deployed app: **Requirements:** 1. Create an AI Gateway API key in Vercel dashboard 2. Add `AI_GATEWAY_API_KEY` to Vercel environment variables 3. Add the key to your local `.env.local` file 4. Install the `ai` package (Vercel AI SDK) 5. Redeploy to apply environment variables **Implementation hints:** - AI Gateway is in the Vercel dashboard sidebar - Environment variables need to be added for all environments (Production, Preview, Development) - Use `.env.local.example` as a template - The `ai` package is version 5.0+ (unified SDK) - After adding env vars, trigger a new deployment ## Step 1: Create AI Gateway API Key 1. Go to your [Vercel Dashboard](https://vercel.com/dashboard) 2. Click **AI Gateway** in the top navigation bar 3. Click **Create API Key** in the sidebar 4. Click **Create Key** 5. Name it `ai-review-summary-key` 6. Copy the key (you'll need it in next steps) **Key format:** ``` AI_GATEWAY_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxx ``` \*\*Warning: Keep Your Key Secret\*\* This key provides access to AI models. Never commit it to git or share it publicly. ## Step 2: Add to Vercel Environment Variables 1. Go to your project in Vercel dashboard 2. Navigate to **Settings** → **Environment Variables** 3. Click **Add New** 4. Enter details: - **Name**: `AI_GATEWAY_API_KEY` - **Value**: (paste your key) - **Environments**: Use the default "All Environments" to include Production, Preview, and Development. 5. Click **Save** **Why all three environments?** - Production: Live site uses this - Preview: Branch deployments use this - Development: Vercel CLI (`vercel dev`) uses this ## Step 3: Configure Local Environment Create `.env.local` in your project root: ```bash # .env.local AI_GATEWAY_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxx ``` **Verify it's in .gitignore:** ```bash cat .gitignore | grep .env.local ``` Should output: `.env.local` If not, add it: ```bash echo ".env.local" >> .gitignore ``` ## Step 4: Install AI SDK ```bash pnpm add ai ``` The `ai` package is Vercel's unified AI SDK. It works with AI Gateway and supports multiple providers (Anthropic, OpenAI, Google, etc.). **What you get:** - `generateText()` - One-shot text generation - `streamText()` - Streaming responses - `generateObject()` - Structured output with Zod - Provider-agnostic API **Version check:** ```bash pnpm list ai ``` Should show `ai@5.x.x` or newer. ## Step 5: Redeploy with Environment Variables Environment variables only apply to new deployments. Trigger a redeploy: ```bash git add . git commit -m "chore: configure AI Gateway environment variables" git push ``` Vercel automatically deploys. The new deployment will have access to `AI_GATEWAY_API_KEY`. **Verify deployment:** 1. Wait for deployment to complete 2. Check deployment logs - no env var warnings 3. Your app is ready for AI features (we'll add them in the next lesson) ## Understanding AI Gateway **Without AI Gateway:** ``` Your App → Anthropic API ↓ - Manage API keys yourself - Handle rate limits manually - No cost tracking - No failover - Direct billing from Anthropic ``` **With AI Gateway:** ``` Your App → AI Gateway → Anthropic API ↓ - Single API key - Auto rate limiting - Cost dashboard - Automatic retries - Model fallbacks - Vercel billing ``` **Key benefits:** 1. **Unified interface** - One API key for all AI providers 2. **Production resilience** - Retries, timeouts, fallbacks 3. **Cost tracking** - Dashboard shows usage per model 4. **Rate limiting** - Prevents runaway costs 5. **Provider flexibility** - Switch models without code changes ## AI Gateway Dashboard Visit your AI Gateway dashboard to see: - **API Calls** - Total requests - **Token Usage** - Input + output tokens - **Cost** - Estimated spend - **Models Used** - Which models are being called - **Errors** - Failed requests **Currently:** - 0 API calls (we haven't made any yet) - $0.00 cost We'll generate our first AI summaries in the next lesson and watch these numbers update. ## Model Strings AI Gateway uses strings for each model name: ```typescript // Anthropic Claude model: "anthropic/claude-sonnet-4.5" // OpenAI GPT model: "openai/gpt-4-turbo" // Google Gemini model: "google/gemini-2.0-flash-001" ``` **Format:** `provider/model-name` No provider-specific packages needed. The AI SDK handles everything. ## Environment Variable Best Practices **Local development:** - `.env.local` - Your actual key (git-ignored) - `.env.local.example` - Template (committed) **Production:** - Vercel Environment Variables - Encrypted, accessible to your app - Never hardcode keys in source code **Team workflow:** 1. Team member clones repo 2. Copies `.env.local.example` to `.env.local` 3. Adds their own AI Gateway key 4. Runs `pnpm dev` ## Commit ```bash git add .env.local.example .gitignore package.json pnpm-lock.yaml git commit -m "chore: set up AI Gateway and install AI SDK" git push ``` ## Done-When - [ ] AI Gateway API key created - [ ] Key added to Vercel environment variables (all 3 environments) - [ ] Local `.env.local` file created with key - [ ] AI SDK (`ai` package) installed - [ ] Redeployed with environment variables - [ ] `.env.local.example` created for team ## What's Next Your app is configured for AI. In the next lesson, you'll write your first AI-powered feature: a `summarizeReviews` function that uses Claude to generate review summaries. You'll see `generateText` in action and watch your AI Gateway dashboard track usage. *** **Sources:** - [Vercel AI Gateway Documentation](https://vercel.com/docs/ai-gateway) - [Vercel AI SDK](https://sdk.vercel.ai) - [Environment Variables in Next.js](https://nextjs.org/docs/app/building-your-application/configuring/environment-variables) --- title: "First AI Summary" description: "Build your first AI feature using the Vercel AI SDK's generateText function. Create a summarizeReviews function that uses Claude to generate concise review summaries." canonical_url: "https://vercel.com/academy/ai-summary-app-with-nextjs/first-ai-summary" md_url: "https://vercel.com/academy/ai-summary-app-with-nextjs/first-ai-summary.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2025-12-11T16:46:26.343Z" content_type: "lesson" course: "ai-summary-app-with-nextjs" course_title: "Creating an AI Summary App with Next.js" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # First AI Summary # First AI summary You've built a great review site, but users have to read all reviews to understand a product. AI can summarize hundreds of reviews into a few sentences, saving users time and highlighting key themes. ## Outcome Create a `summarizeReviews` function that uses Claude via AI Gateway to generate review summaries, and display them on product pages. ## Fast Track 1. Create `lib/ai-summary.ts` with `summarizeReviews(product)` using `generateText({ model: "anthropic/claude-sonnet-4.5", prompt })` 2. Create `components/ai-review-summary.tsx` as async Server Component that calls `await summarizeReviews(product)` 3. Add `` to `app/[productId]/page.tsx`, test at `/mower` ## Hands-on Exercise 2.2 Implement AI-powered review summarization: **Requirements:** 1. Create `lib/ai-summary.ts` with a `summarizeReviews` function 2. Use `generateText` from the AI SDK with model `"anthropic/claude-sonnet-4.5"` 3. Write a basic prompt that includes all reviews 4. Create an `AIReviewSummary` component to display the summary 5. Add the component to product pages **Implementation hints:** - `generateText` returns `{ text }` with the generated content - Use template literals to build the prompt - The function should be async (AI calls take time) - Keep the prompt simple for now (we'll improve it in the next lesson) - The component should be a Server Component (it awaits the async function) ## Understanding generateText The AI SDK provides `generateText` for one-shot text generation: ```typescript import { generateText } from "ai"; const { text } = await generateText({ model: "anthropic/claude-sonnet-4.5", prompt: "Your instructions here", }); ``` **Key parameters:** - `model`: AI Gateway model string - `prompt`: Your instructions and context **Response:** Returns an object with `text` (the generated string) plus metadata like token usage. ## Step 1: Create AI Summary Function Create `lib/ai-summary.ts`: ```typescript title="lib/ai-summary.ts" import { generateText } from "ai"; import { Product } from "./types"; export async function summarizeReviews(product: Product): Promise { const prompt = `Summarize the following customer reviews for the ${product.name} product: ${product.reviews.map((review) => review.review).join("\n\n")} Provide a concise summary of the main themes and sentiments in 2-3 sentences.`; try { const { text } = await generateText({ model: "anthropic/claude-sonnet-4.5", prompt, }); return text; } catch (error) { console.error("Failed to generate summary:", error); throw new Error("Unable to generate review summary. Please try again."); } } ``` **What this does:** 1. Takes a Product object 2. Builds a prompt with product name and all reviews 3. Calls Claude via AI Gateway 4. Returns the generated summary 5. Handles errors gracefully ## Step 2: Create AI Summary Component Create `components/ai-review-summary.tsx`: ```tsx title="components/ai-review-summary.tsx" import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; import { Product } from "@/lib/types"; import { summarizeReviews } from "@/lib/ai-summary"; import { FiveStarRating } from "./five-star-rating"; export async function AIReviewSummary({ product }: { product: Product }) { const summary = await summarizeReviews(product); const averageRating = product.reviews.reduce((acc, review) => acc + review.stars, 0) / product.reviews.length; return (
AI Summary

Based on {product.reviews.length} customer ratings

{averageRating.toFixed(1)} out of 5

{summary}

); } ``` **Key features:** - Server Component (no `"use client"`) - Awaits `summarizeReviews()` before rendering - Displays average rating with stars - Shows review count - Clean card layout ## Step 3: Add to Product Page Update `app/[productId]/page.tsx`: ```tsx title="app/[productId]/page.tsx" {3,23} import { Metadata } from "next"; import { notFound } from "next/navigation"; import { getProduct, getProducts } from "@/lib/sample-data"; import { Reviews } from "@/components/reviews"; import { AIReviewSummary } from "@/components/ai-review-summary"; export default async function ProductPage({ params, }: { params: Promise<{ productId: string }>; }) { const { productId } = await params; let product; try { product = await getProduct(productId); } catch (error) { notFound(); } return (

{product.name}

{product.description}

); } // ... (generateStaticParams and generateMetadata remain the same) ``` ## Try It 1. **Run your dev server:** ```bash pnpm dev ``` 2. **Visit a product page:** ``` http://localhost:3000/mower ``` 3. **Watch the terminal:** ``` GET /mower 200 in 2.3s ``` That 2+ second delay is AI generation time. 4. **See the AI summary:** - Summary card appears above reviews - Claude-generated text summarizes all reviews - Average rating and review count displayed 5. **Try different products:** ``` /ecoBright /aquaHeat ``` Each gets its own unique AI-generated summary. 6. **Check AI Gateway dashboard:** - Go to Vercel dashboard → AI Gateway - See API calls increasing - Check token usage - Monitor costs **What you'll notice:** The summaries work but vary in format and quality. Sometimes they include ratings, sometimes word counts, sometimes extra metadata. That's expected with a basic prompt. We'll fix this in the next lesson with prompt engineering. ## How It Works **Request Flow:** 1. User visits `/mower` 2. Next.js renders `ProductPage` (Server Component) 3. `` component renders 4. Calls `await summarizeReviews(product)` 5. Function sends prompt to AI Gateway 6. AI Gateway forwards to Claude API 7. Claude generates summary (\~2s) 8. Returns text to your app 9. Component renders with summary 10. Full HTML sent to browser **Server-side rendering:** Everything happens on the server. The user's browser never sees the AI Gateway API key or makes direct API calls. ## Performance Note **Current behavior:** - Every page load calls Claude - \~2 second delay per request - Costs tokens every time **Coming in Section 3:** - Smart Caching (Lesson 3.1) - Cache summaries for 1 hour - First request: 2s (generates) - Subsequent requests: 50ms (cached) - 97% cost reduction ## AI Gateway Dashboard After visiting a few product pages, check your dashboard: **Metrics you'll see:** - API Calls: 3+ (one per product visited) - Tokens Used: \~800-1000 per summary - Cost: \~$0.002 per summary - Model: `anthropic/claude-sonnet-4.5` - Success Rate: 100% **Example summary generation:** - Input tokens: \~600 (your prompt + reviews) - Output tokens: \~100 (generated summary) - Total: \~700 tokens - Cost: \~$0.0021 ## Commit ```bash git add lib/ai-summary.ts components/ai-review-summary.tsx app/\[productId\]/page.tsx git commit -m "feat(ai): add basic AI review summarization" git push ``` ## Done-When - [ ] `lib/ai-summary.ts` created with `summarizeReviews` function - [ ] `generateText` working with Claude via AI Gateway - [ ] `AIReviewSummary` component displays summaries - [ ] Summaries appear on all product pages - [ ] AI Gateway dashboard shows API calls and costs - [ ] No errors in terminal or browser console ## What's Next Your AI feature works, but the summaries are inconsistent. In the next lesson, you'll use prompt engineering techniques—few-shot examples, tone guidance, and output constraints—to make summaries production-ready with consistent format and quality. *** **Sources:** - [AI SDK generateText](https://sdk.vercel.ai/docs/reference/ai-sdk-core/generate-text) - [Anthropic Claude Models](https://docs.anthropic.com/claude/docs/models-overview) - [Next.js Server Components](https://nextjs.org/docs/app/building-your-application/rendering/server-components) --- title: "Prompt Engineering" description: "Improve AI summaries with prompt engineering techniques. Learn few-shot prompting, tone guidance, and output formatting to make summaries production-ready with consistent quality." canonical_url: "https://vercel.com/academy/ai-summary-app-with-nextjs/prompt-engineering" md_url: "https://vercel.com/academy/ai-summary-app-with-nextjs/prompt-engineering.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2025-12-11T16:46:26.374Z" content_type: "lesson" course: "ai-summary-app-with-nextjs" course_title: "Creating an AI Summary App with Next.js" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Prompt Engineering # Prompt engineering Your prompt is the entire contract between you and the AI. A vague prompt gets vague results. Specific instructions with examples produce consistent output. Right now, summaries vary in format, length, and sometimes include unwanted metadata. Let's fix that. ## Outcome Refine the `summarizeReviews` prompt to produce consistent, well-formatted summaries that match review sentiment and follow a specific structure. ## Fast Track 1. Calculate `averageRating` and add tone guidance (`1-2: negative, 3: neutral, 4-5: positive`) 2. Add 3 few-shot examples showing "Customers like..." format with pros/cons structure 3. Add response cleanup: `.trim().replace(/^"/, "").replace(/"$/, "").replace(/[\[\(]\d+ words[\]\)]/g, "")` ## Hands-on Exercise 2.3 Improve the prompt for production-quality summaries: **Requirements:** 1. Calculate average rating to determine tone 2. Add 3 few-shot examples showing ideal summary format 3. Include tone guidance (negative/neutral/positive) based on ratings 4. Specify output constraints (length, format, what to avoid) 5. Clean up the AI response (trim quotes, remove word counts) **Implementation hints:** - Few-shot examples teach by showing, not telling - Use `.reduce()` to calculate average rating - Use regex to clean common AI formatting artifacts - Keep examples in the prompt consistent with your requirements - Add `maxTokens` and `temperature` parameters ## Solution Update `lib/ai-summary.ts`: ```typescript title="lib/ai-summary.ts" import { generateText } from "ai"; import { Product } from "./types"; export async function summarizeReviews(product: Product): Promise { const averageRating = product.reviews.reduce((acc, review) => acc + review.stars, 0) / product.reviews.length; const prompt = `Write a summary of the reviews for the ${ product.name } product. The product's average rating is ${averageRating} out of 5 stars. Your goal is to highlight the most common themes and sentiments expressed by customers. If multiple themes are present, try to capture the most important ones. If no patterns emerge but there is a shared sentiment, capture that instead. Try to use natural language and keep the summary concise. Use a maximum of 4 sentences and 30 words. Don't include any word count or character count. No need to reference which reviews you're summarizing. Do not reference the star rating in the summary. Start the summary with "Customers like…" or "Customers mention…" Here are 3 examples of good summaries: Example 1: Customers like the quality, space, fit and value of the sport equipment bag case. They mention it's heavy duty, has lots of space and pockets, and can fit all their gear. They also appreciate the portability and appearance. That said, some disagree on the zipper. Example 2: Customers like the quality, ease of installation, and value of the transport rack. They mention that it holds on to everything really well, and is reliable. Some complain about the wind noise, saying it makes a whistling noise at high speeds. Opinions are mixed on fit, and performance. Example 3: Customers like the quality and value of the insulated water bottle. They say it keeps drinks cold for hours and the lid seals well. Some customers have different opinions on size and durability. Hit the following tone based on rating: - 1-2 stars: negative - 3 stars: neutral - 4-5 stars: positive The customer reviews to summarize are as follows: ${product.reviews .map((review, i) => `Review ${i + 1}:\n${review.review}`) .join("\n\n")}`; try { const { text } = await generateText({ model: "anthropic/claude-sonnet-4.5", prompt, maxOutputTokens: 1000, temperature: 0.75, }); // Clean up the response return text .trim() .replace(/^"/, "") .replace(/\"$/, "") .replace(/[\[\(]\d+ words[\]\)]/g, ""); } catch (error) { console.error("Failed to generate summary:", error); throw new Error("Unable to generate review summary. Please try again."); } } ``` ## Breaking Down the Improvements **1. Calculate average rating:** ```typescript const averageRating = product.reviews.reduce((acc, review) => acc + review.stars, 0) / product.reviews.length; ``` Used to determine tone and provide context to the AI. **2. Clear constraints:** ``` Use a maximum of 4 sentences and 30 words. Don't include any word count or character count. Do not reference the star rating in the summary. ``` **3. Few-shot examples:** ``` Here are 3 examples of good summaries: Example 1: Customers like the quality, space, fit and value... Example 2: Customers like the quality, ease of installation... Example 3: Customers like the quality and value... ``` These teach the AI the exact format and style you want. **4. Tone guidance:** ``` Hit the following tone based on rating: - 1-2 stars: negative - 3 stars: neutral - 4-5 stars: positive ``` **5. Model parameters:** ```typescript maxOutputTokens: 1000, // Limit output length temperature: 0.75, // Balance creativity and consistency ``` **6. Response cleanup:** ```typescript return text .trim() // Remove whitespace .replace(/^"/, "") // Remove leading quote .replace(/"$/, "") // Remove trailing quote .replace(/[\[\(]\d+ words[\]\)]/g, ""); // Remove word counts like "(30 words)" ``` ## Try It 1. **Save the file** and visit a product page 2. **Compare before/after** on `/mower`: - **Before**: "The Mower3000 receives mixed reviews. Rating: 3.0 stars. (45 words)" - **After**: "Customers mention the Mower3000 is quiet and autonomous but struggles with slopes and boundary wire setup. Some love it, others find it misses spots. Opinions are mixed on reliability." 3. **Test different sentiments**: - `/mower` (mixed, \~3.0) - Should be neutral - `/ecoBright` (\~4.0) - Should be positive - `/aquaHeat` (\~4.3) - Should be positive 4. **Check consistency** - Refresh the same page multiple times: - Always starts with "Customers like..." or "Customers mention..." - No word counts or star ratings in output - Consistent length and format 5. **Check AI Gateway dashboard**: - Token usage per request \~800-1000 tokens - Cost \~$0.002 per summary - Consistent response times ## Prompt Engineering Techniques Used **Few-Shot Prompting:** Show 3 examples instead of describing the format. The AI learns from examples better than descriptions. **Constraint Specification:** Be explicit about what you don't want ("Don't include word count") not just what you do want. **Tone Mapping:** Connect data (average rating) to desired output (tone). The AI uses this context to adjust language. **Output Formatting:** Specify the starting phrase ("Customers like...") to ensure consistency across all summaries. **Parameter Tuning:** - `maxTokens: 1000` limits output length - `temperature: 0.75` balances creativity with consistency (0.0 = deterministic, 1.0 = creative) ## Understanding Temperature **Temperature controls randomness:** | Temperature | Behavior | Best For | | ----------- | ---------------------- | ------------------------------- | | 0.0 - 0.3 | Deterministic, focused | Code, facts, structured data | | 0.4 - 0.7 | Balanced | Summaries, explanations | | 0.8 - 1.0 | Creative, varied | Creative writing, brainstorming | **For review summaries:** `0.75` gives enough variation to sound natural while maintaining consistency. ## Token Usage Comparison **Before (basic prompt):** - Input tokens: \~300 - Output tokens: \~80 - Total: \~380 tokens - Cost: \~$0.0011 **After (engineered prompt):** - Input tokens: \~600 (longer prompt with examples) - Output tokens: \~100 (slightly longer summaries) - Total: \~700 tokens - Cost: \~$0.0021 **Worth it?** Yes. $0.001 extra per summary for consistent, production-quality output is a great trade-off. ## Commit ```bash git add lib/ai-summary.ts git commit -m "feat(ai): improve summaries with prompt engineering" git push ``` ## Done-When - [ ] Average rating calculated for tone guidance - [ ] Few-shot examples added to prompt - [ ] Tone guidance based on ratings - [ ] Output constraints specified - [ ] Response cleanup removes quotes and word counts - [ ] All summaries start with "Customers like..." or "Customers mention..." - [ ] Summaries adapt tone to match review sentiment ## What's Next Your summaries are now consistent and production-ready. In the next lesson, you'll replace the blocking `generateText` call with `streamText` to show users content as it's generated—word by word—instead of waiting for the full response. *** **Sources:** - [Prompt Engineering Guide](https://www.promptingguide.ai) - [Anthropic Prompt Engineering](https://docs.anthropic.com/claude/docs/prompt-engineering) - [AI SDK generateText Parameters](https://sdk.vercel.ai/docs/reference/ai-sdk-core/generate-text) --- title: "Streaming Summaries" description: "Replace blocking generateText with streamText for real-time AI responses. Users see content appear word-by-word instead of waiting for the full response." canonical_url: "https://vercel.com/academy/ai-summary-app-with-nextjs/streaming-summaries" md_url: "https://vercel.com/academy/ai-summary-app-with-nextjs/streaming-summaries.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2025-12-11T16:46:26.395Z" content_type: "lesson" course: "ai-summary-app-with-nextjs" course_title: "Creating an AI Summary App with Next.js" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Streaming Summaries # Streaming summaries Is anyone in physical pain right now from watching the routes change? If the slowness is killing you, let's fix it with streaming. ## Outcome Replace `generateText` with `streamText` to stream AI summaries in real-time, showing users content as it's generated. ## Fast Track 1. Update `lib/ai-summary.ts`: change `generateText` to `streamText`, return `result.textStream` 2. Convert `AIReviewSummary` to Client Component with `"use client"`, use `useEffect` to consume the stream 3. Test at `/mower`—summary text appears word-by-word instead of all at once ## Hands-on Exercise 2.4 Add streaming to the AI summary feature: **Requirements:** 1. Change `summarizeReviews` to return a stream instead of a string 2. Create a new async function that returns the stream object 3. Update `AIReviewSummary` to consume the stream with React state 4. Show a loading indicator while waiting for first chunk 5. Display text as it streams in **Implementation hints:** - `streamText` returns `{ textStream }` which is an async iterable - Client Components can use `useState` and `useEffect` to handle streams - Use Server Actions to call the streaming function from Client Components - Consider showing "Generating summary..." before first chunk arrives ## Understanding streamText The AI SDK provides `streamText` for streaming responses: ```typescript import { streamText } from "ai"; const result = streamText({ model: "anthropic/claude-sonnet-4-5", prompt: "Your instructions here", }); // result.textStream is an async iterable for await (const chunk of result.textStream) { console.log(chunk); // Each chunk as it arrives } ``` **Key differences from generateText:** - Returns immediately (doesn't wait for full response) - `textStream` yields chunks as they're generated - Better UX for longer responses ## Step 1: Create Streaming Function Update `lib/ai-summary.ts` to add a streaming version: ```typescript title="lib/ai-summary.ts" {1,5-56} import { generateText, streamText } from "ai"; import { Product } from "./types"; // Keep the existing summarizeReviews function for now // Add this new streaming function export async function streamReviewSummary(product: Product) { const averageRating = product.reviews.reduce((acc, review) => acc + review.stars, 0) / product.reviews.length; const prompt = `Write a summary of the reviews for the ${ product.name } product. The product's average rating is ${averageRating} out of 5 stars. Your goal is to highlight the most common themes and sentiments expressed by customers. If multiple themes are present, try to capture the most important ones. If no patterns emerge but there is a shared sentiment, capture that instead. Try to use natural language and keep the summary concise. Use a maximum of 4 sentences and 30 words. Don't include any word count or character count. No need to reference which reviews you're summarizing. Do not reference the star rating in the summary. Start the summary with "Customers like…" or "Customers mention…" Here are 3 examples of good summaries: Example 1: Customers like the quality, space, fit and value of the sport equipment bag case. They mention it's heavy duty, has lots of space and pockets, and can fit all their gear. They also appreciate the portability and appearance. That said, some disagree on the zipper. Example 2: Customers like the quality, ease of installation, and value of the transport rack. They mention that it holds on to everything really well, and is reliable. Some complain about the wind noise, saying it makes a whistling noise at high speeds. Opinions are mixed on fit, and performance. Example 3: Customers like the quality and value of the insulated water bottle. They say it keeps drinks cold for hours and the lid seals well. Some customers have different opinions on size and durability. Hit the following tone based on rating: - 1-2 stars: negative - 3 stars: neutral - 4-5 stars: positive The customer reviews to summarize are as follows: ${product.reviews .map((review, i) => `Review ${i + 1}:\n${review.review}`) .join("\n\n")}`; const result = streamText({ model: "anthropic/claude-sonnet-4-5", prompt, maxTokens: 1000, temperature: 0.75, }); return result; } ``` **What changed:** - Added `streamText` import - New `streamReviewSummary` function returns the stream result directly - Same prompt as the engineered version from 2.3 ## Step 2: Create Server Action Create `app/actions/stream-summary.ts`: ```typescript title="app/actions/stream-summary.ts" "use server"; import { streamReviewSummary } from "@/lib/ai-summary"; import { getProduct } from "@/lib/sample-data"; export async function getStreamingSummary(productSlug: string) { const product = getProduct(productSlug); const result = await streamReviewSummary(product); return result.toTextStreamResponse(); } ``` This Server Action wraps the streaming function and returns a response that can be consumed by the client. ## Step 3: Create Streaming Component Create `components/streaming-summary.tsx`: ```tsx title="components/streaming-summary.tsx" "use client"; import { useEffect, useState } from "react"; import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; import { FiveStarRating } from "./five-star-rating"; import { Product } from "@/lib/types"; export function StreamingSummary({ product }: { product: Product }) { const [summary, setSummary] = useState(""); const [isLoading, setIsLoading] = useState(true); const averageRating = product.reviews.reduce((acc, review) => acc + review.stars, 0) / product.reviews.length; useEffect(() => { async function fetchStream() { setIsLoading(true); setSummary(""); try { const response = await fetch(`/api/summary/${product.slug}`); if (!response.ok) { throw new Error("Failed to fetch summary"); } const reader = response.body?.getReader(); const decoder = new TextDecoder(); if (!reader) { throw new Error("No reader available"); } setIsLoading(false); while (true) { const { done, value } = await reader.read(); if (done) break; const chunk = decoder.decode(value, { stream: true }); setSummary((prev) => prev + chunk); } } catch (error) { console.error("Stream error:", error); setSummary("Unable to generate summary. Please try again."); setIsLoading(false); } } fetchStream(); }, [product.slug]); return (
AI Summary

Based on {product.reviews.length} customer ratings

{averageRating.toFixed(1)} out of 5

{isLoading ? ( Generating summary... ) : ( summary )}

); } ``` **Key features:** - `"use client"` directive for React hooks - `useState` tracks the streaming text and loading state - `useEffect` fetches and consumes the stream - Shows "Generating summary..." while waiting for first chunk - Appends each chunk as it arrives ## Step 4: Create API Route for Streaming Create `app/api/summary/[slug]/route.ts`: ```typescript title="app/api/summary/[slug]/route.ts" import { streamReviewSummary } from "@/lib/ai-summary"; import { getProduct } from "@/lib/sample-data"; export async function GET( request: Request, { params }: { params: Promise<{ slug: string }> } ) { const { slug } = await params; let product; try { product = getProduct(slug); } catch { return new Response("Product not found", { status: 404 }); } const result = await streamReviewSummary(product); return result.toTextStreamResponse(); } ``` **What this does:** - Creates a streaming endpoint at `/api/summary/[slug]` - Calls the streaming function and returns a text stream response - The client reads this stream chunk by chunk ## Step 5: Update Product Page Update `app/[productId]/page.tsx` to use the streaming component: ```tsx title="app/[productId]/page.tsx" {5,31} import { Metadata } from "next"; import { notFound } from "next/navigation"; import { getProduct, getProducts } from "@/lib/sample-data"; import { Reviews } from "@/components/reviews"; import { StreamingSummary } from "@/components/streaming-summary"; export default async function ProductPage({ params, }: { params: Promise<{ productId: string }>; }) { const { productId } = await params; let product; try { product = getProduct(productId); } catch { notFound(); } return (

{product.name}

{product.description}

); } // ... (generateStaticParams and generateMetadata remain the same) ``` ## Try It 1. **Run your dev server:** ```bash pnpm dev ``` 2. **Visit a product page:** ``` http://localhost:3000/mower ``` 3. **Watch the summary stream in:** - "Generating summary..." appears first - Text starts appearing word-by-word - Summary completes in 2-3 seconds - Much better UX than waiting for full response! 4. **Compare the experience:** - **Before (blocking):** Blank card → wait 2-3s → full text appears - **After (streaming):** Loading text → immediate first word → text flows in 5. **Test different products:** - `/ecoBright` - Watch it stream - `/aquaHeat` - Each product streams independently ## How Streaming Works **Request flow:** ``` 1. Page loads → StreamingSummary component mounts 2. useEffect triggers → fetches /api/summary/mower 3. API route calls streamReviewSummary(product) 4. streamText sends request to Claude 5. Claude generates tokens one at a time 6. Each token streams back through: Claude → AI Gateway → Your API → Client 7. Client appends each chunk to state 8. React re-renders with new text 9. User sees words appear progressively ``` **Why it feels faster:** - Time to first byte: \~200ms (instead of waiting 2-3s) - User sees progress immediately - Perceived performance is much better - Same total generation time, better UX ## Streaming vs Blocking Comparison | Aspect | generateText (Blocking) | streamText (Streaming) | | --------------------- | ------------------------ | ------------------------------ | | Time to first content | 2-3 seconds | \~200ms | | Total time | 2-3 seconds | 2-3 seconds | | User experience | Wait, then see all | See progress immediately | | Implementation | Simpler | Slightly more complex | | Best for | Short responses, caching | Longer responses, real-time UX | ## When to Use Streaming **Use streaming when:** - Response takes >1 second to generate - User is waiting and watching - Content is being read (summaries, explanations) - You want engaging, dynamic UX **Use blocking when:** - Response is very short - Result will be cached - Processing happens in background - Structured output (generateObject doesn't stream content) ## Commit ```bash git add lib/ai-summary.ts app/api/summary/\[slug\]/route.ts components/streaming-summary.tsx app/\[productId\]/page.tsx git commit -m "feat(ai): add streaming summaries with streamText" git push ``` ## Done-When - [ ] `streamReviewSummary` function created using `streamText` - [ ] API route `/api/summary/[slug]` returns streaming response - [ ] `StreamingSummary` Client Component consumes the stream - [ ] Loading state shows "Generating summary..." - [ ] Text appears word-by-word as it streams - [ ] Product page uses streaming component - [ ] Verified streaming works on all product pages ## What's Next Streaming gives users immediate feedback for text summaries. In the next lesson, you'll use `generateObject` to extract structured data—pros, cons, and themes—with full type safety using Zod schemas. Note: `generateObject` returns complete objects, not streams, since partial structured data isn't useful. *** **Sources:** - [AI SDK streamText](https://sdk.vercel.ai/docs/reference/ai-sdk-core/stream-text) - [Streaming in Next.js](https://nextjs.org/docs/app/building-your-application/routing/loading-ui-and-streaming) - [ReadableStream API](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream) --- title: "Structured Output" description: "Use the AI SDK's generateObject function to extract structured data from reviews. Define Zod schemas for type-safe structured output and display pros, cons, and key themes alongside summaries." canonical_url: "https://vercel.com/academy/ai-summary-app-with-nextjs/structured-output" md_url: "https://vercel.com/academy/ai-summary-app-with-nextjs/structured-output.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2025-12-11T16:46:26.416Z" content_type: "lesson" course: "ai-summary-app-with-nextjs" course_title: "Creating an AI Summary App with Next.js" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Structured Output # Structured output Text summaries are great, but structured data opens new possibilities. Extract specific insights—pros, cons, key themes—in a format you can filter, sort, and display in creative ways. The AI SDK's `generateObject` with Zod schemas makes this type-safe and reliable. ## Outcome Use `generateObject` to extract structured insights (pros, cons, themes) from reviews with full type safety using Zod schemas. ## Fast Track 1. Add `ReviewInsightsSchema` to `lib/types.ts` with `pros`, `cons`, `themes` arrays using `.describe()` hints 2. Create `getReviewInsights(product)` in `lib/ai-summary.ts` using `generateObject({ schema: ReviewInsightsSchema })` 3. Create `components/review-insights.tsx` with two-column pros/cons grid and theme tags, add to product page ## Hands-on Exercise 2.5 Extract structured insights from reviews: **Requirements:** 1. Create a Zod schema for review insights (pros, cons, themes) 2. Add a `getReviewInsights` function using `generateObject` 3. Display pros and cons in a two-column layout 4. Show key themes as tags/badges 5. Keep the existing summary (don't replace it) **Implementation hints:** - `generateObject` requires a Zod schema as `schema` parameter - The function returns typed data matching your schema - Use arrays for pros/cons/themes (3-5 items each) - Display insights in a Card below the AI summary - Consider using a grid layout for pros/cons columns ## Understanding generateObject The AI SDK provides `generateObject` for structured data extraction: ```typescript import { generateObject } from "ai"; import { z } from "zod"; const schema = z.object({ pros: z.array(z.string()), cons: z.array(z.string()), }); const { object } = await generateObject({ model: "anthropic/claude-sonnet-4.5", schema, prompt: "Extract pros and cons from these reviews...", }); // object is fully typed: { pros: string[], cons: string[] } ``` **Benefits:** - Type-safe output (TypeScript knows the structure) - Automatic validation (Zod ensures correct format) - Structured data (easy to filter, sort, display) ## Step 1: Define Insights Schema Add to `lib/types.ts`: ```typescript title="lib/types.ts" {27-34} import { z } from "zod"; // Review schema export const ReviewSchema = z.object({ reviewer: z.string(), stars: z.number().min(1).max(5), review: z.string(), date: z.string(), }); // Product schema export const ProductSchema = z.object({ slug: z.string(), name: z.string(), description: z.string(), reviews: z.array(ReviewSchema), }); // Infer TypeScript types export type Review = z.infer; export type Product = z.infer; // Review insights schema export const ReviewInsightsSchema = z.object({ pros: z.array(z.string()).describe("Positive aspects mentioned in reviews"), cons: z.array(z.string()).describe("Negative aspects or concerns"), themes: z.array(z.string()).describe("Key themes across all reviews"), }); export type ReviewInsights = z.infer; ``` ## Step 2: Create Insights Function Add `generateObject` to your imports and the `getReviewInsights` function to `lib/ai-summary.ts`: ```typescript title="lib/ai-summary.ts" {1,2,17-50} import { generateText, generateObject, streamText } from "ai"; import { Product, ReviewInsights, ReviewInsightsSchema } from "./types"; function buildSummaryPrompt(product: Product): string { // ... (existing prompt helper from 2.4) } export function streamReviewSummary(product: Product) { // ... (existing streaming function from 2.4) } export async function summarizeReviews(product: Product): Promise { // ... (existing blocking function from 2.3) } export async function getReviewInsights( product: Product ): Promise { const averageRating = product.reviews.reduce((acc, review) => acc + review.stars, 0) / product.reviews.length; const prompt = `Analyze the following customer reviews for the ${product.name} product (average rating: ${averageRating}/5). Extract: 1. Pros: 3-5 positive aspects customers appreciate 2. Cons: 3-5 negative aspects or concerns mentioned 3. Themes: 3-5 key themes that emerge across reviews Be specific and concise. Each item should be 3-7 words. Reviews: ${product.reviews .map((review, i) => `Review ${i + 1} (${review.stars} stars):\n${review.review}`) .join("\n\n")}`; try { const { object } = await generateObject({ model: "anthropic/claude-sonnet-4.5", schema: ReviewInsightsSchema, prompt, }); return object; } catch (error) { console.error("Failed to extract insights:", error); throw new Error("Unable to extract review insights. Please try again."); } } ``` **What changed:** - Added `generateObject` to imports (line 1) - Added `ReviewInsights` and `ReviewInsightsSchema` to type imports (line 2) - Added new `getReviewInsights` function at the end of the file ## Step 3: Create Insights Component Create `components/review-insights.tsx`: ```tsx title="components/review-insights.tsx" import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; import { Product } from "@/lib/types"; import { getReviewInsights } from "@/lib/ai-summary"; export async function ReviewInsights({ product }: { product: Product }) { const insights = await getReviewInsights(product); return ( Key Insights {/* Pros and Cons Grid */}
{/* Pros */}

Pros

    {insights.pros.map((pro, i) => (
  • {pro}
  • ))}
{/* Cons */}

Cons

    {insights.cons.map((con, i) => (
  • {con}
  • ))}
{/* Themes */}

Key Themes

{insights.themes.map((theme, i) => ( {theme} ))}
); } ``` ## Step 4: Add to Product Page Update `app/[productId]/page.tsx`: ```tsx title="app/[productId]/page.tsx" {6,34} import { Metadata } from "next"; import { notFound } from "next/navigation"; import { getProduct, getProducts } from "@/lib/sample-data"; import { Reviews } from "@/components/reviews"; import { StreamingSummary } from "@/components/streaming-summary"; import { ReviewInsights } from "@/components/review-insights"; export default async function ProductPage({ params, }: { params: Promise<{ productId: string }>; }) { const { productId } = await params; let product; try { product = getProduct(productId); } catch { notFound(); } return (

{product.name}

{product.description}

); } // ... (generateStaticParams and generateMetadata remain the same) ``` ## Try It 1. **Visit a product page:** ``` http://localhost:3000/mower ``` 2. **You should see:** - AI Summary card (existing) - **New: Key Insights card** with: - Pros column (green checkmarks) - Cons column (red X marks) - Theme tags at the bottom 3. **Example output for Mower3000:** **Pros:** - ✓ Quiet operation - ✓ Autonomous cutting - ✓ Good app integration - ✓ Quality mulching **Cons:** - ✗ Struggles on slopes - ✗ Boundary wire setup difficult - ✗ Gets stuck occasionally - ✗ Limited customer support **Themes:** - Autonomous Operation | Slope Challenges | Setup Complexity | Quiet Performance 4. **Check AI Gateway dashboard:** - Now making 2 API calls per product page - One for summary (`generateText`) - One for insights (`generateObject`) - Combined cost: \~$0.004 per page load ## How generateObject Works **Request:** ```typescript generateObject({ schema: ReviewInsightsSchema, prompt: "Extract pros, cons, themes...", }) ``` **Behind the scenes:** 1. AI SDK sends your Zod schema to Claude 2. Claude generates structured JSON matching the schema 3. AI SDK validates the response against your schema 4. Returns typed object (TypeScript knows the structure) **Response:** ```typescript { pros: ["Quiet operation", "Autonomous cutting", ...], cons: ["Struggles on slopes", "Setup difficult", ...], themes: ["Autonomous Operation", "Slope Challenges", ...] } ``` Fully typed. TypeScript autocomplete works. Runtime validation ensures correctness. ## Type Safety Benefits **Without Zod:** ```typescript const data: any = await callAI(); // Hope it has the right shape const pros = data.pros; // Maybe? Could be undefined or wrong type ``` **With Zod and generateObject:** ```typescript const { object } = await generateObject({ schema: ReviewInsightsSchema, // ... }); // TypeScript knows: object.pros; // string[] object.cons; // string[] object.themes; // string[] // Runtime: Zod validates before returning // If AI returns wrong shape, error is caught immediately ``` ## Schema Descriptions Notice the `.describe()` calls: ```typescript pros: z.array(z.string()).describe("Positive aspects mentioned in reviews") ``` These descriptions are sent to the AI to guide extraction. More descriptive schemas = better results. ## Performance Note **Current behavior:** - 2 API calls per page load (summary + insights) - \~4 seconds total generation time - \~$0.004 per page load **Coming in Section 3:** - Smart caching reduces this to 1-time cost - Subsequent loads: instant (cached) - 97% cost reduction ## Commit ```bash git add lib/types.ts lib/ai-summary.ts components/review-insights.tsx app/\[productId\]/page.tsx git commit -m "feat(ai): add structured output with generateObject" git push ``` ## Done-When - [ ] `ReviewInsightsSchema` defined in types - [ ] `getReviewInsights` function using `generateObject` - [ ] `ReviewInsights` component displays pros/cons/themes - [ ] Insights appear on all product pages - [ ] Data is fully type-safe - [ ] Pros/cons displayed in two-column grid - [ ] Themes shown as tags ## What's Next You now have both text summaries and structured insights. But every page load costs tokens. In Section 3, you'll add Next.js 16 smart caching to generate once and reuse, reducing costs by 97% while maintaining great UX. *** **Sources:** - [AI SDK generateObject](https://sdk.vercel.ai/docs/reference/ai-sdk-core/generate-object) - [Zod Schemas](https://zod.dev) - [Structured Output Best Practices](https://docs.anthropic.com/claude/docs/tool-use) --- title: "Smart Caching" description: "Add Next.js caching to AI functions to eliminate redundant API calls. Use the \"use cache\" directive with cacheLife and cacheTag to serve instant responses while keeping content fresh." canonical_url: "https://vercel.com/academy/ai-summary-app-with-nextjs/smart-caching" md_url: "https://vercel.com/academy/ai-summary-app-with-nextjs/smart-caching.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2025-12-11T16:46:26.459Z" content_type: "lesson" course: "ai-summary-app-with-nextjs" course_title: "Creating an AI Summary App with Next.js" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Smart Caching # Smart caching Every page load currently calls Claude twice—once for the summary, once for insights. That's \~$0.004 per page view. With caching, you generate once and reuse the result for all subsequent requests. Same great UX, 97% cost reduction. ## Outcome Cache AI-generated summaries and insights using Next.js `"use cache"` directive to serve instant responses and reduce API costs by 97%. ## Fast Track 1. Enable `cacheComponents` in `next.config.ts` 2. Add `"use cache"` directive to AI functions with `cacheLife("hours")` 3. Add `cacheTag` for on-demand invalidation 4. Test first load (generates) vs subsequent loads (cached) ## Hands-on Exercise 3.1 Add caching to AI functions to improve performance and reduce costs: **Requirements:** 1. Enable Cache Components in `next.config.ts` 2. Add `"use cache"` directive to `summarizeReviews` 3. Add `"use cache"` directive to `getReviewInsights` 4. Use `cacheLife("hours")` for 1-hour cache duration 5. Add `cacheTag` for targeted cache invalidation 6. Verify caching behavior in development **Implementation hints:** - `"use cache"` goes at the top of the function body - `cacheLife` accepts built-in profiles: `"seconds"`, `"minutes"`, `"hours"`, `"days"`, `"weeks"`, `"max"` - `cacheTag` enables on-demand invalidation with `revalidateTag()` - First request generates, subsequent requests are instant ## Understanding Next.js 16 Caching Next.js 16 provides the `"use cache"` directive for declarative caching: ```typescript import { cacheLife, cacheTag } from "next/cache"; export async function getData(id: string) { "use cache"; cacheLife("hours"); cacheTag(`data-${id}`); // Expensive operation - only runs on cache miss return await fetchData(id); } ``` **How it works:** 1. First call: Executes function, caches result 2. Subsequent calls: Returns cached result instantly 3. After cache lifetime expires: Background regeneration on next request 4. Manual invalidation: Use `revalidateTag()` to clear specific caches **Benefits:** - Instant response times (no AI call) - Reduced API costs (only regenerate periodically) - Fresh content (automatic revalidation) - Simple syntax (just add the directive) ## Step 1: Enable Cache Components Update `next.config.ts` (or create it): ```typescript title="next.config.ts" {4} import type { NextConfig } from "next"; const nextConfig: NextConfig = { cacheComponents: true, }; export default nextConfig; ``` This enables the `"use cache"` directive across your application. ## Step 2: Cache Summary Function Update `lib/ai-summary.ts`: ```typescript title="lib/ai-summary.ts" import { generateText, generateObject } from "ai"; import { cacheLife, cacheTag } from "next/cache"; import { Product, ReviewInsights, ReviewInsightsSchema } from "./types"; export async function summarizeReviews(product: Product): Promise { "use cache"; cacheLife("hours"); cacheTag(`product-summary-${product.slug}`); const averageRating = product.reviews.reduce((acc, review) => acc + review.stars, 0) / product.reviews.length; const prompt = `Write a summary of the reviews for the ${ product.name } product. The product's average rating is ${averageRating} out of 5 stars. Your goal is to highlight the most common themes and sentiments expressed by customers. If multiple themes are present, try to capture the most important ones. If no patterns emerge but there is a shared sentiment, capture that instead. Try to use natural language and keep the summary concise. Use a maximum of 4 sentences and 30 words. Don't include any word count or character count. No need to reference which reviews you're summarizing. Do not reference the star rating in the summary. Start the summary with "Customers like…" or "Customers mention…" Here are 3 examples of good summaries: Example 1: Customers like the quality, space, fit and value of the sport equipment bag case. They mention it's heavy duty, has lots of space and pockets, and can fit all their gear. They also appreciate the portability and appearance. That said, some disagree on the zipper. Example 2: Customers like the quality, ease of installation, and value of the transport rack. They mention that it holds on to everything really well, and is reliable. Some complain about the wind noise, saying it makes a whistling noise at high speeds. Opinions are mixed on fit, and performance. Example 3: Customers like the quality and value of the insulated water bottle. They say it keeps drinks cold for hours and the lid seals well. Some customers have different opinions on size and durability. Hit the following tone based on rating: - 1-2 stars: negative - 3 stars: neutral - 4-5 stars: positive The customer reviews to summarize are as follows: ${product.reviews .map((review, i) => `Review ${i + 1}:\n${review.review}`) .join("\n\n")}`; try { const { text } = await generateText({ model: "anthropic/claude-sonnet-4.5", prompt, maxOutputTokens: 1000, temperature: 0.75, }); // Clean up the response return text .trim() .replace(/^"/, "") .replace(/"$/, "") .replace(/[\[\(]\d+ words[\]\)]/g, ""); } catch (error) { console.error("Failed to generate summary:", error); throw new Error("Unable to generate review summary. Please try again."); } } ``` **What changed:** - Added `"use cache"` directive at function start - Added `cacheLife("hours")` for 1-hour cache duration - Added `cacheTag` with product slug for targeted invalidation ## Step 3: Cache Insights Function Update the `getReviewInsights` function in the same file: ```typescript title="lib/ai-summary.ts" export async function getReviewInsights( product: Product ): Promise { "use cache"; cacheLife("hours"); cacheTag(`product-insights-${product.slug}`); const averageRating = product.reviews.reduce((acc, review) => acc + review.stars, 0) / product.reviews.length; const prompt = `Analyze the following customer reviews for the ${product.name} product (average rating: ${averageRating}/5). Extract: 1. Pros: 3-5 positive aspects customers appreciate 2. Cons: 3-5 negative aspects or concerns mentioned 3. Themes: 3-5 key themes that emerge across reviews Be specific and concise. Each item should be 3-7 words. Reviews: ${product.reviews .map( (review, i) => `Review ${i + 1} (${review.stars} stars):\n${review.review}` ) .join("\n\n")}`; try { const { object } = await generateObject({ model: "anthropic/claude-sonnet-4.5", schema: ReviewInsightsSchema, prompt, }); return object; } catch (error) { console.error("Failed to extract insights:", error); throw new Error("Unable to extract review insights. Please try again."); } } ``` **Both functions now:** - Cache results for 1 hour (`cacheLife("hours")`) - Use product-specific tags for invalidation - Return instantly on subsequent requests - Automatically revalidate in background ## Try It 1. **Clear any existing cache and restart:** ```bash rm -rf .next pnpm dev ``` 2. **Visit a product page:** ``` http://localhost:3000/mower ``` 3. **Check terminal output (first load):** ``` GET /mower 200 in 4.2s ``` **Timing:** - Summary generation: \~2s - Insights generation: \~2s - Total: \~4s - Cost: \~$0.004 4. **Refresh the page (cached load):** ``` GET /mower 200 in 52ms ``` **Timing:** - Summary: instant (cached) - Insights: instant (cached) - Total: \~50ms - Cost: $0.00 **That's 98.7% faster and free!** 5. **Try different products:** - `/ecoBright` - First load: \~4s, subsequent: \~50ms - `/aquaHeat` - First load: \~4s, subsequent: \~50ms Each product has its own cache entry. ## Cache Invalidation Manually invalidate cache when reviews change: ```typescript title="app/actions.ts" "use server"; import { revalidateTag } from "next/cache"; export async function invalidateProductCache(productSlug: string) { revalidateTag(`product-summary-${productSlug}`); revalidateTag(`product-insights-${productSlug}`); } ``` **Use cases:** - New review submitted → invalidate that product's cache - Product updated → invalidate cache - Admin triggers refresh → invalidate cache ## Built-in Cache Profiles Next.js provides several built-in `cacheLife` profiles: | Profile | Stale (client) | Revalidate (server) | Expire | | ----------- | -------------- | ------------------- | ------ | | `"seconds"` | 0 | 1s | 60s | | `"minutes"` | 5m | 1m | 1h | | `"hours"` | 5m | 1h | 1d | | `"days"` | 5m | 1d | 1w | | `"weeks"` | 5m | 1w | 30d | | `"max"` | 5m | 30d | 1y | **For AI summaries:** `"hours"` is a good default—fresh enough for new reviews, cached enough to save costs. ## Performance Comparison **Before caching:** ``` Page Load: ├─ Generate summary: 2.1s ($0.002) ├─ Generate insights: 2.0s ($0.002) └─ Total: 4.1s ($0.004) 10,000 page views/month: └─ Cost: $40.00 ``` **After caching (1 hour revalidation):** ``` First Load (1 per hour per product): ├─ Generate summary: 2.1s ($0.002) ├─ Generate insights: 2.0s ($0.002) └─ Total: 4.1s ($0.004) Cached Loads (all subsequent): ├─ Return summary: 5ms ($0.00) ├─ Return insights: 5ms ($0.00) └─ Total: 50ms ($0.00) 10,000 page views/month (1 hour cache): ├─ Cache hits: ~9,760 loads ($0.00) ├─ Cache misses: ~240 loads ($0.96) └─ Total: $0.96 (97.6% reduction) ``` **Benefits:** - 98.7% faster response time - 97.6% cost reduction - Same UX (users see instant results) - Fresh data (revalidates hourly) ## Production Deployment When you deploy to Vercel, caching works across all serverless function invocations: 1. **First user** visits `/mower` → generates summary and insights (\~4s) 2. **All subsequent users** get cached results (\~50ms) 3. **After 1 hour** → background regeneration on next request 4. **Users always** get instant responses (cached or fresh) **Vercel's distributed cache:** - Shared across all function instances - Persistent across deployments - Edge-cached for global performance ## Commit ```bash git add next.config.ts lib/ai-summary.ts git commit -m "feat(ai): add caching to reduce costs and improve performance" git push ``` ## Done-When - [ ] `cacheComponents` enabled in `next.config.ts` - [ ] `summarizeReviews` uses `"use cache"` directive - [ ] `getReviewInsights` uses `"use cache"` directive - [ ] Cache tags added for both functions - [ ] Cache lifetime set to 1 hour with `cacheLife("hours")` - [ ] Verified first load generates content - [ ] Verified subsequent loads return instantly - [ ] Cache invalidation function created - [ ] Performance improvement measured (\~98% faster) - [ ] Cost reduction achieved (\~97% savings) ## What's next You've built a complete AI-powered review summary feature with caching for cost reduction. Next, you'll prepare for production by handling AI failures gracefully and configuring model fallbacks. *** **Sources:** - [Next.js "use cache" Directive](https://nextjs.org/docs/app/api-reference/directives/use-cache) - [cacheLife Function](https://nextjs.org/docs/app/api-reference/functions/cacheLife) - [cacheTag Function](https://nextjs.org/docs/app/api-reference/functions/cacheTag) --- title: "When AI Goes Wrong" description: "Build resilient AI features that handle failures gracefully. Use AI Gateway fallbacks to swap models automatically, show users helpful error states, and understand your costs before they surprise you." canonical_url: "https://vercel.com/academy/ai-summary-app-with-nextjs/when-ai-goes-wrong" md_url: "https://vercel.com/academy/ai-summary-app-with-nextjs/when-ai-goes-wrong.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2025-12-11T16:46:26.509Z" content_type: "lesson" course: "ai-summary-app-with-nextjs" course_title: "Creating an AI Summary App with Next.js" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # When AI Goes Wrong # When AI goes wrong It's all fun and games when the APIs are working. But what happens when they're not? ## Outcome Build error handling that keeps your app useful when AI fails, configure AI Gateway fallbacks, and understand your AI costs. ## Fast track 1. Create `components/ai-summary-fallback.tsx` showing "Customer Reviews" with rating (no AI text) 2. Wrap `summarizeReviews()` call in try/catch, return `` on error 3. Vercel → AI Gateway → Settings → Add fallback chain: Claude Sonnet → GPT-4 Turbo → Claude Haiku ## Hands-on exercise 3.2 Make your AI features production-ready: **Requirements:** 1. Create a fallback component for when AI summaries fail 2. Add error handling to the AI summary component 3. Configure model fallbacks in AI Gateway 4. Review your cost dashboard and understand the numbers **Implementation hints:** - React error boundaries catch render errors - Fallback UI should still be useful (show reviews without summary) - AI Gateway fallbacks are configured per API key - Cost tracking helps you predict bills before they arrive ## What can go wrong? AI features fail in ways traditional features don't: ``` Your App → AI Gateway → Claude API ↓ - Claude outage (503) - Rate limit hit (429) - Request timeout (>30s) - Invalid response - Context too long (400) ``` **Common failure scenarios:** | Scenario | Cause | Frequency | | ---------------- | ------------------ | --------------------- | | Provider outage | Claude/OpenAI down | Rare (few times/year) | | Rate limiting | Too many requests | Common at scale | | Timeout | Slow generation | Occasional | | Context overflow | Too many reviews | Edge case | | Invalid API key | Misconfiguration | Development | Without handling, users see blank screens or cryptic errors. With handling, they see your app—just without the AI parts. ## Step 1: Create a fallback component When AI fails, show something useful instead of an error. Create `components/ai-summary-fallback.tsx`: ```tsx title="components/ai-summary-fallback.tsx" import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; import { Product } from "@/lib/types"; import { FiveStarRating } from "./five-star-rating"; export function AISummaryFallback({ product }: { product: Product }) { const averageRating = product.reviews.reduce((acc, review) => acc + review.stars, 0) / product.reviews.length; return (
Customer Reviews

Based on {product.reviews.length} customer ratings

{averageRating.toFixed(1)} out of 5

Read the reviews below to see what customers are saying about this product.

); } ``` **What changed from the AI version:** - Title says "Customer Reviews" instead of "AI Summary" - No AI-generated text - Guides users to read individual reviews - Same layout, so no jarring UI shift ## Step 2: Add error handling to the AI component Update `components/ai-review-summary.tsx` to handle failures: ```tsx title="components/ai-review-summary.tsx" {4,11-14} import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; import { Product } from "@/lib/types"; import { summarizeReviews } from "@/lib/ai-summary"; import { FiveStarRating } from "./five-star-rating"; import { AISummaryFallback } from "./ai-summary-fallback"; export async function AIReviewSummary({ product }: { product: Product }) { const averageRating = product.reviews.reduce((acc, review) => acc + review.stars, 0) / product.reviews.length; let summary: string; try { summary = await summarizeReviews(product); } catch (error) { console.error("AI summary failed, showing fallback:", error); return ; } return (
AI Summary

Based on {product.reviews.length} customer ratings

{averageRating.toFixed(1)} out of 5

{summary}

); } ``` **Now when AI fails:** 1. Error is logged (for debugging) 2. Fallback component renders 3. User still sees useful content 4. No ugly error screens ## Step 2b: Add error handling to ReviewInsights Apply the same pattern to the insights component. Update `components/review-insights.tsx`: ```tsx title="components/review-insights.tsx" {4,7-12} import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; import { Product } from "@/lib/types"; import { getReviewInsights } from "@/lib/ai-summary"; export async function ReviewInsights({ product }: { product: Product }) { let insights; try { insights = await getReviewInsights(product); } catch (error) { console.error("AI insights failed:", error); return null; // Silently fail - summary is more important } return ( Key Insights {/* Pros and Cons Grid */}
{/* Pros */}

Pros

    {insights.pros.map((pro, i) => (
  • {pro}
  • ))}
{/* Cons */}

Cons

    {insights.cons.map((con, i) => (
  • {con}
  • ))}
{/* Themes */}

Key Themes

{insights.themes.map((theme, i) => ( {theme} ))}
); } ``` **Key difference from summary fallback:** - Returns `null` instead of a fallback component - Insights are supplementary—if they fail, users still have the summary and reviews - The page remains functional without showing an error ## Step 3: Understand AI Gateway fallbacks AI Gateway can automatically try a different model when your primary model fails. This happens at the infrastructure level—no code changes needed. **How fallbacks work:** ``` Request → AI Gateway ↓ Try Claude 4.5 ↓ [429 Rate Limit] ↓ Try GPT-4 Turbo (fallback) ↓ Success → Return response ``` **Configure fallbacks in Vercel dashboard:** 1. Go to **AI Gateway** → **Settings** 2. Find **Model Fallbacks** 3. Configure fallback chain: ``` Primary: anthropic/claude-sonnet-4.5 Fallback 1: openai/gpt-4-turbo Fallback 2: anthropic/claude-haiku-3.5 ``` **When fallbacks trigger:** - Primary model returns 429 (rate limit) - Primary model returns 503 (service unavailable) - Primary model times out (configurable) **When fallbacks don't trigger:** - 400 errors (bad request—your code needs fixing) - 401 errors (auth failed—your key is wrong) - Successful responses (even if the content is poor) \*\*Note: Fallback cost considerations\*\* Different models have different costs. GPT-4 Turbo is roughly 3x more expensive than Claude Sonnet for similar quality. Claude Haiku is 10x cheaper but less capable. Choose fallbacks that balance reliability with cost. ## Step 4: Understand your costs AI Gateway tracks every request. Before your app scales, understand the numbers. **Find your cost dashboard:** 1. Vercel Dashboard → **AI Gateway** 2. Click **Usage** tab 3. See breakdown by model, day, and request type **What you'll see:** ``` Last 30 days ───────────────────────────────── Total requests: 1,247 Total tokens: 892,340 Estimated cost: $2.68 By model: ├─ anthropic/claude-sonnet-4.5 │ ├─ Requests: 1,198 (96%) │ ├─ Tokens: 856,200 │ └─ Cost: $2.57 │ └─ anthropic/claude-haiku-3.5 ├─ Requests: 49 (4%) ├─ Tokens: 36,140 └─ Cost: $0.11 ``` **Understanding token costs:** See current pricing at: - [Anthropic Pricing](https://www.anthropic.com/pricing) - [OpenAI Pricing](https://openai.com/pricing) As a rough guide, Claude Sonnet is a mid-tier model (\~$3/1M input tokens), Claude Haiku is budget-friendly (\~10x cheaper), and GPT-4 Turbo is premium (\~3x more expensive than Sonnet). Always check official pricing pages for current rates. **Your summary function costs:** - Input: \~600 tokens (prompt + reviews) - Output: \~100 tokens (summary) - Cost per summary: \~$0.0021 with Claude Sonnet **At scale:** - 1,000 summaries/month: \~$2.10 - 10,000 summaries/month: \~$21.00 - 100,000 summaries/month: \~$210.00 With caching (from lesson 3.1), most requests hit cache. Real costs are 90-97% lower. ## Choosing the right model Not every AI call needs the best model. Match model capability to task complexity. **When to use Claude Sonnet 4.5 (your current choice):** - Complex analysis (structured insights) - Nuanced summarization - When quality directly impacts user trust **When to use Claude Haiku 3.5 (10x cheaper):** - Simple summaries - Classification tasks - High-volume, lower-stakes operations **When to use GPT-4o Mini (cheapest):** - Very simple tasks - Fallback when cost matters more than quality - Testing and development **Experiment:** Try switching your summary function to Haiku: ```typescript title="lib/ai-summary.ts" const { text } = await generateText({ model: "anthropic/claude-haiku-3.5", // Was claude-sonnet-4.5 prompt, }); ``` Compare the output quality. For short review summaries, you might not notice a difference—at 10x lower cost. ## Cost optimization checklist Before going to production, verify: - [ ] **Caching enabled** (lesson 3.1) — Most requests should hit cache - [ ] **Fallbacks configured** — Don't let outages break your app - [ ] **Error handling** — Graceful degradation when AI fails - [ ] **Model selection** — Right model for the job, not always the fanciest - [ ] **Rate limits understood** — Know your provider limits **Estimated monthly cost for a review site:** | Traffic | Without caching | With caching (1hr) | | ------------- | --------------- | ------------------ | | 1,000 views | $4.00 | $0.12 | | 10,000 views | $40.00 | $1.20 | | 100,000 views | $400.00 | $12.00 | Caching is the biggest cost lever. Use it. ## Try it 1. **Test error handling:** - Temporarily break your API key in `.env.local` - Visit a product page - Verify fallback component appears - Fix the API key 2. **Check your dashboard:** - Visit Vercel → AI Gateway → Usage - See your actual costs so far - Note which models you're using 3. **Configure fallbacks:** - AI Gateway → Settings → Model Fallbacks - Add at least one fallback model - (You won't see it trigger unless your primary fails) 4. **Optional: Test a cheaper model:** - Change model to `anthropic/claude-haiku-3.5` - Generate a few summaries - Compare quality vs cost savings ## Commit ```bash git add components/ai-summary-fallback.tsx components/ai-review-summary.tsx git commit -m "feat(ai): add error handling and fallback UI" git push ``` ## Done-when - [ ] Fallback component created - [ ] AI summary component handles errors gracefully - [ ] ReviewInsights component handles errors gracefully - [ ] Understand AI Gateway fallback configuration - [ ] Reviewed cost dashboard - [ ] Know approximate cost per summary - [ ] Understand model tradeoffs (quality vs cost) ## What's next Your AI features handle failures gracefully. But how do you know when something's wrong in production? In the next lesson, you'll set up observability—logging, analytics, and alerts—so you know what's happening before users complain. *** **Sources:** - [Vercel AI Gateway](https://vercel.com/docs/ai-gateway) - [Anthropic Pricing](https://www.anthropic.com/pricing) - [OpenAI Pricing](https://openai.com/pricing) - [AI SDK Error Handling](https://sdk.vercel.ai/docs/ai-sdk-core/error-handling) --- title: "Observability and Monitoring" description: "Set up observability for AI features in production. Use AI Gateway analytics to track usage and costs, add structured logging for debugging, and configure alerts before problems become incidents." canonical_url: "https://vercel.com/academy/ai-summary-app-with-nextjs/observability-monitoring" md_url: "https://vercel.com/academy/ai-summary-app-with-nextjs/observability-monitoring.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2025-12-11T16:46:26.530Z" content_type: "lesson" course: "ai-summary-app-with-nextjs" course_title: "Creating an AI Summary App with Next.js" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Observability and Monitoring # Observability and monitoring Your AI features work. Users are happy. But something will break eventually—and you want to know before your users tell you. Observability means understanding what's happening in production without staring at logs all day. ## Outcome Set up monitoring for your AI features using AI Gateway analytics, structured logging, and cost alerts. ## Fast track 1. Vercel → AI Gateway → Analytics to review requests/tokens/costs breakdown 2. Add `console.log(JSON.stringify({ event, requestId, productSlug, duration, tokens }))` to AI functions 3. AI Gateway → Settings → Alerts: set daily cost threshold ($10) and error rate threshold (5%) ## Hands-on exercise 3.3 Add observability to your AI features: **Requirements:** 1. Review AI Gateway analytics (requests, tokens, costs, errors) 2. Add structured logging to `summarizeReviews` and `getReviewInsights` 3. Configure alerts for cost thresholds and error rates 4. Test logging by generating some AI requests **Implementation hints:** - AI Gateway dashboard shows real-time and historical data - Log request metadata (product slug, token count, duration) - Alerts can notify via email, Slack, or webhooks - Start with conservative thresholds and adjust based on real usage ## AI Gateway analytics AI Gateway tracks everything automatically. No code changes needed. **Find your analytics:** 1. Vercel Dashboard → **AI Gateway** 2. Click **Analytics** tab **What you'll see:** ``` Overview (Last 7 days) ──────────────────────────────────────── Total requests: 2,847 Success rate: 99.2% Avg latency: 1,847ms Total tokens: 2.1M Estimated cost: $6.32 Requests by model: ├─ anthropic/claude-sonnet-4.5 2,614 (91.8%) ├─ anthropic/claude-haiku-3.5 198 (7.0%) └─ openai/gpt-4-turbo 35 (1.2%) Errors: ├─ 429 Rate Limited: 18 ├─ 503 Service Error: 4 └─ Timeout: 1 ``` **Key metrics to watch:** | Metric | Healthy | Investigate | Alert | | ------------ | ------------- | ----------- | --------- | | Success rate | Above 99% | 95-99% | Below 95% | | Avg latency | Under 2s | 2-5s | Over 5s | | Error rate | Under 1% | 1-5% | Over 5% | | Daily cost | Within budget | 2x budget | 5x budget | ## Understanding the dashboard **Requests over time:** Shows request volume by hour/day. Look for: - Unexpected spikes (bot traffic? viral post?) - Sudden drops (deployment broke something?) - Patterns (peak hours, quiet periods) **Latency distribution:** Shows p50, p90, p99 latency. Look for: - p50 \~1-2s (typical AI generation) - p99 under 5s (occasional slow requests are normal) - p99 over 10s (something's wrong) **Token usage:** Shows input vs output tokens. Look for: - Input tokens >> Output tokens (normal for summarization) - Unexpected token growth (prompts getting longer?) - Spikes correlating with specific products (long reviews?) **Cost breakdown:** Shows cost by model and day. Look for: - Steady growth (normal with traffic) - Sudden jumps (fallbacks triggering? new feature?) - Model distribution (are fallbacks firing more than expected?) ## Adding structured logging AI Gateway tracks aggregate metrics. For debugging specific requests, add your own logging. Update `lib/ai-summary.ts` to add logging while keeping the `"use cache"` directive from Lesson 3.1: ```typescript title="lib/ai-summary.ts" import { generateText, generateObject } from "ai"; import { cacheLife, cacheTag } from "next/cache"; import { Product, ReviewInsights, ReviewInsightsSchema } from "./types"; export async function summarizeReviews(product: Product): Promise { "use cache"; cacheLife("hours"); cacheTag(`product-summary-${product.slug}`); const startTime = Date.now(); const requestId = crypto.randomUUID(); console.log(JSON.stringify({ event: "ai_request_start", requestId, function: "summarizeReviews", productSlug: product.slug, reviewCount: product.reviews.length, timestamp: new Date().toISOString(), })); const averageRating = product.reviews.reduce((acc, review) => acc + review.stars, 0) / product.reviews.length; const prompt = `Write a summary of the reviews for the ${product.name} product...`; // Your existing prompt try { const { text, usage } = await generateText({ model: "anthropic/claude-sonnet-4.5", prompt, maxTokens: 1000, temperature: 0.75, }); const duration = Date.now() - startTime; console.log(JSON.stringify({ event: "ai_request_success", requestId, function: "summarizeReviews", productSlug: product.slug, duration, inputTokens: usage?.promptTokens, outputTokens: usage?.completionTokens, totalTokens: usage?.totalTokens, timestamp: new Date().toISOString(), })); return text.trim(); } catch (error) { const duration = Date.now() - startTime; console.error(JSON.stringify({ event: "ai_request_error", requestId, function: "summarizeReviews", productSlug: product.slug, duration, error: error instanceof Error ? error.message : "Unknown error", timestamp: new Date().toISOString(), })); throw new Error("Unable to generate review summary. Please try again."); } } ``` **What this logs:** **Request start:** ```json { "event": "ai_request_start", "requestId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "function": "summarizeReviews", "productSlug": "mower", "reviewCount": 12, "timestamp": "2024-01-15T14:32:01.234Z" } ``` **Request success:** ```json { "event": "ai_request_success", "requestId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "function": "summarizeReviews", "productSlug": "mower", "duration": 2341, "inputTokens": 847, "outputTokens": 89, "totalTokens": 936, "timestamp": "2024-01-15T14:32:03.575Z" } ``` **Request error:** ```json { "event": "ai_request_error", "requestId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "function": "summarizeReviews", "productSlug": "mower", "duration": 5023, "error": "Rate limit exceeded", "timestamp": "2024-01-15T14:32:06.257Z" } ``` **Why structured logging?** - **Searchable** — Find all errors for a specific product - **Parseable** — Tools like Vercel Logs, Datadog, or Axiom can parse JSON - **Correlatable** — Request IDs link start → success/error - **Measurable** — Track duration, tokens, and patterns over time ## Viewing logs in Vercel **Find your logs:** 1. Vercel Dashboard → Your project 2. Click **Logs** tab 3. Filter by: - Level: `error` (show only errors) - Time: Last hour/day/week - Search: `ai_request_error` or `productSlug: mower` **Example log search:** ``` // Find all AI errors in the last 24 hours ai_request_error // Find all requests for a specific product productSlug: aquaHeat // Find slow requests (>3 seconds) duration > 3000 ``` ## Setting up alerts Don't wait for users to tell you something's broken. Set up alerts. **AI Gateway alerts:** 1. Vercel Dashboard → **AI Gateway** → **Settings** 2. Scroll to **Alerts** 3. Configure thresholds: ``` Cost alerts: ├─ Daily spend > $10 → Email notification ├─ Daily spend > $50 → Slack notification └─ Daily spend > $100 → PagerDuty (wake someone up) Error alerts: ├─ Error rate > 5% → Email notification ├─ Error rate > 10% → Slack notification └─ Error rate > 25% → PagerDuty Latency alerts: ├─ p99 latency > 10s → Email notification └─ p99 latency > 30s → Slack notification ``` **Project-level alerts (Vercel):** 1. Project → **Settings** → **Notifications** 2. Configure: - Deployment failures - Function errors - Usage thresholds **Start conservative:** It's better to get too many alerts initially and tune them down than to miss something critical. ## Debugging production issues When something goes wrong, here's how to investigate: **1. Check AI Gateway dashboard** - Error spike? What time did it start? - Which model? (primary or fallback?) - What error codes? (429, 503, timeout?) **2. Check Vercel logs** - Search for `ai_request_error` - Filter to the timeframe - Look for patterns (same product? same error?) **3. Correlate with deployments** - Did a deployment happen right before the errors? - Check deployment logs for build issues **4. Check provider status** - [Anthropic Status](https://status.anthropic.com) - [OpenAI Status](https://status.openai.com) - If provider is down, your fallbacks should be handling it **Common issues and causes:** | Symptom | Likely cause | Fix | | -------------------- | -------------------- | ---------------------------------------------------- | | Sudden 429 spike | Rate limit hit | Add fallback model, implement backoff | | All requests failing | Bad API key | Check env vars in Vercel | | Slow responses | Provider degradation | Fallbacks should kick in | | Cost spike | Cache not working | Check `"use cache"` directive and `cacheLife` config | | Token overflow | Long reviews | Truncate input or paginate | ## Production monitoring checklist Before going live, verify: - [ ] **AI Gateway analytics accessible** — Can you see requests, costs, errors? - [ ] **Structured logging added** — JSON logs with request IDs and metadata - [ ] **Cost alerts configured** — Get notified before bills surprise you - [ ] **Error alerts configured** — Know when things break - [ ] **Fallbacks working** — Verified backup models are configured - [ ] **Logs searchable** — Can find specific requests when debugging ## Try it 1. **Explore your AI Gateway dashboard:** - How many requests have you made? - What's your average latency? - Any errors? 2. **Add structured logging:** - Update `summarizeReviews` with the logging code - Generate a few summaries - Check Vercel logs for the JSON output 3. **Set up a cost alert:** - AI Gateway → Settings → Alerts - Set a daily spend threshold (even $1 for testing) - Verify you receive the alert notification 4. **Simulate an error (optional):** - Temporarily break your API key - Visit a product page - Check that error logs appear correctly - Fix the API key ## Commit ```bash git add lib/ai-summary.ts git commit -m "feat(observability): add structured logging to AI functions" git push ``` ## Done-when - [ ] Explored AI Gateway analytics dashboard - [ ] Understand key metrics (requests, latency, tokens, costs) - [ ] Added structured logging to AI functions - [ ] Configured at least one alert (cost or error) - [ ] Know how to search logs in Vercel - [ ] Understand debugging workflow for production issues ## What's next Your AI features are observable. You'll know when things break, why they broke, and how much it's costing. Time to wrap up the course and review everything you've built. *** **Sources:** - [Vercel AI Gateway Analytics](https://vercel.com/docs/ai-gateway) - [Vercel Logs](https://vercel.com/docs/observability/runtime-logs) - [Structured Logging Best Practices](https://www.honeycomb.io/blog/structured-logging-best-practices) --- title: "Course Complete" description: "Wrap up the course with a review of what you've built. See your complete AI-powered review summarization app and explore ideas for extending it further." canonical_url: "https://vercel.com/academy/ai-summary-app-with-nextjs/complete" md_url: "https://vercel.com/academy/ai-summary-app-with-nextjs/complete.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2025-12-11T16:46:26.550Z" content_type: "lesson" course: "ai-summary-app-with-nextjs" course_title: "Creating an AI Summary App with Next.js" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Course Complete # Conclusion You started with a blank Next.js project. Now you have a production-ready AI-powered review summarization app deployed to Vercel. Let's recap. ## What you built **Section 1: Foundations** - Modern Next.js 16 app with TypeScript and Tailwind - Type-safe data layer with Zod schemas - Review display components with star ratings - Dynamic routes with static generation - Deployed to Vercel with automatic CI/CD **Section 2: AI SDK integration** - AI Gateway setup with secure API keys - First AI summary using `generateText` - Prompt engineering for consistent output - Streaming summaries with `streamText` - Structured data extraction with `generateObject` **Section 3: Production readiness** - Smart caching for 97% cost reduction - Error handling with graceful fallbacks - AI Gateway model fallbacks - Cost awareness and optimization - Observability with structured logging and alerts ## The complete architecture ``` User visits /mower ↓ Next.js checks cache ↓ [Cache HIT] → Return instantly (50ms) ↓ [Cache MISS] → Call AI Gateway ↓ AI Gateway → Claude API ↓ Generate summary (~2s) ↓ Cache result (1 hour) ↓ Return to user ``` **Performance:** - First visit: \~2-4s (AI generation) - Cached visits: \~50ms (instant) - Cost: \~$0.002 per unique summary - With caching: 97% cost reduction ## Key patterns you learned **1. Server Components for AI** AI calls happen server-side. Users never see your API keys. No client-side JavaScript needed for AI features. ```tsx // Server Component - runs on server export async function AIReviewSummary({ product }) { const summary = await summarizeReviews(product); // Server-side AI call return
{summary}
; } ``` **2. Prompt engineering matters** A good prompt is the difference between "meh" and "production-ready." ```typescript // Bad: "Summarize these reviews" // Good: Specific format, examples, constraints, tone guidance ``` **3. Structured output with Zod** Don't parse AI text—tell the model exactly what shape you want. ```typescript const { object } = await generateObject({ model: "anthropic/claude-sonnet-4.5", schema: ReviewInsightsSchema, // Zod schema prompt, }); // object is typed and validated ``` **4. Cache aggressively** AI is expensive. Cache results. Most users see cached content—same UX, fraction of the cost. ```typescript export async function summarizeReviews(product: Product) { "use cache"; cacheLife("hours"); cacheTag(`product-summary-${product.slug}`); // AI call runs once, result cached for 1 hour const { text } = await generateText({ ... }); return text; } ``` **5. Fail gracefully** AI will fail. Handle it. Show users something useful instead of error screens. ## Where to go from here Your app is complete, but there's always more to explore: **Add more AI features:** - Product comparisons ("How does X compare to Y?") - Review sentiment over time ("Are reviews getting better or worse?") - Personalized recommendations ("Based on your history...") **Improve the UI:** - Loading skeletons during AI generation - "Regenerate" button for on-demand summaries - Admin dashboard for cache management **Scale considerations:** - Rate limiting for public APIs - Queue-based processing for bulk operations - Multi-tenant cost tracking **Advanced AI patterns:** - Streaming responses for long-form content - Multi-model pipelines (cheap model for filtering, expensive for analysis) - Fine-tuning for domain-specific language You did it! I am very proud of you! **Course complete!** 🎉 --- title: "Python on Vercel" description: "Keep your Python stack and ship it with your frontend. Build a FastAPI + Next.js furniture app deployed as one Vercel project." canonical_url: "https://vercel.com/academy/python-on-vercel" md_url: "https://vercel.com/academy/python-on-vercel.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-09-22T04:50:18.829Z" content_type: "course" lessons: 6 estimated_time: lesson_urls: - "https://vercel.com/academy/python-on-vercel/install-vercel-cli.md" - "https://vercel.com/academy/python-on-vercel/explore-fastapi-starter.md" - "https://vercel.com/academy/python-on-vercel/explore-nextjs-starter.md" - "https://vercel.com/academy/python-on-vercel/run-with-vercel-dev.md" - "https://vercel.com/academy/python-on-vercel/wire-nextjs-to-fastapi.md" - "https://vercel.com/academy/python-on-vercel/deploy-to-prod.md" --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Python on Vercel Vercel is now Vercel for backends. Your Next.js frontend and Python API can deploy together as one Vercel project. Hazel Home gives us the test case: a furniture storefront still using mock inventory even though its FastAPI backend is ready. We'll connect the two locally, with Next.js at `/` and FastAPI under `/api`, then deploy the finished app under one domain. Course repo: [vercel-labs/academy-python-course](https://github.com/vercel-labs/academy-python-course) ## What You'll Build A furniture inventory app with a FastAPI endpoint at `/api/items` and a Next.js frontend that displays the inventory. Both parts deploy together as one Vercel project. \*\*Note: Python runtime status\*\* Vercel's [Python runtime](https://vercel.com/docs/functions/runtimes/python) is currently in Beta and available on every Vercel plan. ## Prerequisites - Python 3.12+ - Familiarity with FastAPI (you've written routes and run a dev server) - Basic Node.js and npm knowledge (enough to run a Next.js app) - A Vercel account ([Hobby](https://vercel.com/docs/plans/hobby) supports a personal learning project; its fair-use policy excludes commercial use) ## Optional Companion Skill This course has a companion Academy skill that helps you debug `vercel dev`, route matching in `api/index.py`, wiring `app/page.tsx` to `/api/items`, and deployment checks. Install it once: ```bash npx skills add vercel-labs/academy-skills --skill=python-on-vercel -y ``` Then invoke it in chat with prompts like: - "Use the `python-on-vercel` skill and check why `/api/items` returns 404." - "Use the `python-on-vercel` skill and review my `starter/app/page.tsx` wiring." - "Use the `python-on-vercel` skill and walk me through deploy verification." ## What's Covered **Section 1: Setup.** Install the Vercel CLI, then run each side of the starter project locally. **Section 2: Connect the Apps.** Use `vercel dev` to serve the project from one local URL, then replace the frontend's mock inventory with data from FastAPI. **Section 3: Deploy to Vercel.** Deploy the project and verify the frontend and API in production. --- title: "Install the Vercel CLI" description: "Install the Vercel CLI and verify that it can access the intended Vercel account before opening the starter project." canonical_url: "https://vercel.com/academy/python-on-vercel/install-vercel-cli" md_url: "https://vercel.com/academy/python-on-vercel/install-vercel-cli.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-20T18:12:27.037Z" content_type: "lesson" course: "python-on-vercel" course_title: "Python on Vercel" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Install the Vercel CLI # Install and Authenticate the Vercel CLI A global CLI installed during an old workshop has a remarkable ability to reappear at the worst time. Before we open either app, we'll install a current Vercel CLI and connect it to the account we intend to use. ## Outcome Install the Vercel CLI and verify that it can access your Vercel account. ## Hands-on exercise 1.1 ### Install The CLI ships as an npm package. Install it globally so you can run it from anywhere: ```bash npm install -g vercel ``` The CLI requires Node.js. If `node --version` fails, install the LTS release from [nodejs.org](https://nodejs.org) before continuing. ### Authenticate Once installed, log into your Vercel account: ```bash vercel login ``` The CLI starts an [OAuth device flow](https://vercel.com/changelog/new-vercel-cli-login-flow) and prints a verification URL. Open the URL on any browser-capable device, check that the location and request time match, then approve the login. The terminal confirms when authentication finishes: ``` > Success! Authentication complete. ``` ### Verify Confirm the CLI is installed and authenticated: ```bash vercel --version vercel whoami ``` The first command prints the CLI version. The second confirms which account is active. \*\*Note: Token-based auth\*\* In CI, authenticate with a Vercel access token instead of an interactive login. Pass the token to CLI commands with the `--token` option. ## Try It Run both commands and check the output: ```bash vercel --version ``` ``` Vercel CLI 59.1.4 ``` [FastAPI projects require Vercel CLI 48.1.8 or newer](https://vercel.com/docs/frameworks/backend/fastapi). If your version is older, upgrade with `npm install -g vercel@latest` before moving on. ```bash vercel whoami ``` ``` your-vercel-username ``` If `vercel whoami` returns your username, authentication worked. If the command reports that you're logged out, run `vercel login` again. \*\*Warning: Multiple accounts\*\* The CLI stores one active session at a time. If you have a personal and a work Vercel account, `vercel login` will switch you to whichever you authenticate as. Run `vercel whoami` any time you're unsure which account is active. ## Commit We haven't changed any project files, so there is nothing to commit yet. ## Troubleshooting **`vercel login` waits for approval:** Open the verification URL printed in the terminal and enter the device code if prompted. The browser can be on another device. **`vercel: command not found` after install:** The global npm bin directory may be missing from your `PATH`. Run `npm config get prefix` to locate it, then add its `bin` directory to your `PATH`. You can also run the CLI through `npx vercel`. ## Done-When - [ ] `vercel --version` prints a version number - [ ] `vercel whoami` returns your Vercel username ## Solution ```bash npm install -g vercel vercel login vercel --version # prints Vercel CLI 48.1.8 or newer vercel whoami # prints your Vercel username ``` --- title: "Tour the FastAPI Starter" description: "Get the FastAPI starter running locally, explore the /api/items endpoint, and understand how Vercel's Python runtime finds and serves your app from the api/ folder." canonical_url: "https://vercel.com/academy/python-on-vercel/explore-fastapi-starter" md_url: "https://vercel.com/academy/python-on-vercel/explore-fastapi-starter.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-20T18:12:27.062Z" content_type: "lesson" course: "python-on-vercel" course_title: "Python on Vercel" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Tour the FastAPI Starter # Tour the FastAPI Starter The Hazel Home API is small enough to read without a guided expedition: one Python file with two routes. Its location matters as much as its contents because Vercel uses `api/index.py` as the Python entrypoint. ## Outcome Get the FastAPI starter running locally and confirm the `/api/items` endpoint is returning data. ## Hands-on exercise 1.2 ### Get the starter Clone the course starter repo into a folder called `starter` and step into it: ```bash git clone https://github.com/vercel-labs/academy-python-course.git starter cd starter ``` Inside `starter/`, the Python `api/` folder and Next.js `app/` folder sit beside each other at the project root. ### Install dependencies From the project root, install the Python dependencies: ```bash pip install "fastapi[standard]" ``` The `[standard]` extra includes `uvicorn`, which FastAPI uses as its development server. If you already have a virtual environment set up, activate it first. ### Start the server ```bash fastapi dev api/index.py ``` You'll see output like this: ``` INFO: Will watch for changes in these directories: ['/path/to/starter'] INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) INFO: Started reloader process [12345] using WatchFiles INFO: Started server process [12346] INFO: Waiting for application startup. INFO: Application startup complete. ``` ### Explore the endpoints Open `http://localhost:8000/api/items` in your browser. You'll get the full inventory: ```json [ {"id": 1, "name": "Fernwood Sectional", "category": "Seating", "price": 2499.0, "in_stock": true}, {"id": 2, "name": "Knotted Oak Coffee Table", "category": "Tables", "price": 849.0, "in_stock": true}, {"id": 3, "name": "Garrison Bookshelf", "category": "Storage", "price": 629.0, "in_stock": false} ] ``` FastAPI also generates interactive API docs at `http://localhost:8000/docs`. We can use them to inspect responses without reaching for `curl`. ### Read the code Open `starter/api/index.py`. The whole thing is about 20 lines: ```python from fastapi import FastAPI app = FastAPI() items = [...] @app.get("/api") def home(): return {"message": "Hazel Home Furniture API"} @app.get("/api/items") def get_items(): return items ``` The module exposes the FastAPI instance as `app`, the name Vercel expects at a [supported FastAPI entrypoint](https://vercel.com/kb/guide/ship-a-fastapi-app-on-vercel). Renaming that variable prevents Vercel from finding the application. The file path controls routing. Vercel packages Python files in `api/` as functions, and `api/index.py` receives requests under `/api/*`. The FastAPI route declarations include that prefix so they match the full incoming path. ### Check the dependency file Open `starter/pyproject.toml` at the project root: ```toml [project] name = "hazel-home" version = "0.1.0" requires-python = ">=3.12" dependencies = [ "fastapi>=0.141.1", ] ``` Vercel reads this file to install Python dependencies at build time. The `requires-python` field declares the versions that the project supports. The [Python runtime currently supports Python 3.12, 3.13, and 3.14](https://vercel.com/docs/functions/runtimes/python), with 3.12 as the default when the project does not request a supported version. \*\*Note: requirements.txt works too\*\* Vercel also supports `requirements.txt`, plus `Pipfile` with `Pipfile.lock`. We use `pyproject.toml` to declare the supported Python versions and dependencies together. ## Try It With the server running, confirm both endpoints work: ```bash curl http://localhost:8000/api ``` ```json {"message": "Hazel Home Furniture API"} ``` ```bash curl http://localhost:8000/api/items ``` ```json [ {"id":1,"name":"Fernwood Sectional","category":"Seating","price":2499.0,"in_stock":true}, ... ] ``` ## Commit This lesson only inspects the starter, so there are no changes to commit. ## Troubleshooting **`fastapi: command not found`:** The `[standard]` extra installs the `fastapi` CLI along with `uvicorn`. If the command isn't found, your virtual environment may not be activated, or the install didn't complete. Run `pip install "fastapi[standard]"` again inside an active venv. **Port 8000 already in use:** Another process is using the port. Run `fastapi dev api/index.py --port 8001` to use a different port, or kill the existing process with `lsof -ti:8000 | xargs kill`. ## Done-When - [ ] `fastapi dev api/index.py` starts without errors - [ ] `http://localhost:8000/api/items` returns all 8 furniture items - [ ] You can identify the `app` variable and the `api/index.py` entrypoint in the code ## Solution ```python # starter/api/index.py from fastapi import FastAPI app = FastAPI() items = [ {"id": 1, "name": "Fernwood Sectional", "category": "Seating", "price": 2499.00, "in_stock": True}, {"id": 2, "name": "Knotted Oak Coffee Table", "category": "Tables", "price": 849.00, "in_stock": True}, {"id": 3, "name": "Garrison Bookshelf", "category": "Storage", "price": 629.00, "in_stock": False}, {"id": 4, "name": "The Long Table", "category": "Tables", "price": 1199.00, "in_stock": True}, {"id": 5, "name": "Pivot Desk Chair", "category": "Seating", "price": 449.00, "in_stock": True}, {"id": 6, "name": "Ember Side Table", "category": "Tables", "price": 299.00, "in_stock": True}, {"id": 7, "name": "Stacked Nightstand", "category": "Storage", "price": 389.00, "in_stock": False}, {"id": 8, "name": "Canvas Floor Lamp", "category": "Lighting", "price": 219.00, "in_stock": True}, ] @app.get("/api") def home(): return {"message": "Hazel Home Furniture API"} @app.get("/api/items") def get_items(): return items ``` --- title: "Tour the Next.js Starter" description: "Get the Next.js 16 starter running locally, tour the page component, and locate the mock data array that will be replaced by a real FastAPI fetch in the next section." canonical_url: "https://vercel.com/academy/python-on-vercel/explore-nextjs-starter" md_url: "https://vercel.com/academy/python-on-vercel/explore-nextjs-starter.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-20T18:12:27.084Z" content_type: "lesson" course: "python-on-vercel" course_title: "Python on Vercel" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Tour the Next.js Starter # Tour the Next.js Starter Hazel Home already has furniture and a suspiciously polished product grid. Every item comes from a hardcoded array in the page component, which gives us a working interface before the API enters the picture. ## Outcome Get the Next.js starter running locally and locate the mock data that will be replaced by a real API call. ## Hands-on exercise 1.3 ### Install and run ```bash cd starter npm install npm run dev ``` You'll see Turbopack start up. Next.js 16 uses it as the default bundler: ``` ▲ Next.js 16.3.0 (Turbopack) - Local: http://localhost:3000 - Network: http://192.168.x.x:3000 ✓ Starting... ✓ Ready in 612ms ``` Open `http://localhost:3000`. You'll see the Hazel Home storefront with all eight furniture items displayed. ### Read the page component Open `starter/app/page.tsx`. The relevant part is at the top: ```tsx const mockItems: Item[] = [ { id: 1, name: "Fernwood Sectional", category: "Seating", price: 2499, in_stock: true }, { id: 2, name: "Knotted Oak Coffee Table", category: "Tables", price: 849, in_stock: true }, // ... ]; export default function Home() { return ( <>

All Furniture

{mockItems.map((item) => ( // ... ))}
); } ``` The synchronous component maps over `mockItems` and renders a card for each entry. Because the data lives in the same file, the page does not make a network request yet. In Section 2, we'll replace `mockItems` with a request to `/api/items`. We can keep the card markup and change its data source. ### Check the project layout The root of `starter/` has two halves living side by side: ``` starter/ ├── .gitignore # Local build and Vercel metadata ├── api/ │ └── index.py # FastAPI lives here ├── app/ # Next.js app router │ ├── globals.css │ ├── layout.tsx │ └── page.tsx ├── package.json # Next.js dependencies ├── pyproject.toml # Python dependencies ├── next.config.ts ├── postcss.config.mjs └── tsconfig.json ``` Vercel treats the `api/` folder as Python functions and the rest of the project as the Next.js app. Keeping both at the same root lets one Vercel project build them together. ### Check the project config Open `starter/package.json`: ```json { "dependencies": { "next": "^16.3.0", "react": "^19.0.0", "react-dom": "^19.0.0" } } ``` The shared page shell lives in `starter/app/layout.tsx`, including the header, body wrapper, and global stylesheet import. We won't change it in this course. \*\*Note: Tailwind v4\*\* This project uses Tailwind CSS v4. Its setup begins with `@import "tailwindcss"` in `globals.css`, so this starter does not include a Tailwind config file. ## Try It With both apps running simultaneously, FastAPI on port 8000 and Next.js on port 3000, open a second terminal and confirm both are up: ```bash curl http://localhost:8000/api/items | head -c 100 ``` ```json [{"id":1,"name":"Fernwood Sectional","category":"Seating","price":2499.0,"in_stock":true} ``` ```bash curl http://localhost:3000 -s -o /dev/null -w "%{http_code}" ``` ``` 200 ``` The two responses confirm that both sides work independently. Stop the dev servers before the next lesson, where `vercel dev` will serve them from the same local URL. ## Commit We only ran and inspected the starter, so there are no changes to commit. The starter's `.gitignore` already excludes `node_modules/`, `.next/`, virtual environments, and `.vercel/` project metadata. ## Troubleshooting **Port 3000 already in use:** Next.js will automatically try port 3001 if 3000 is taken. The terminal will show the port that it selected; open that URL instead. **Node version error:** [Next.js 16 requires Node.js 20.9 or later](https://nextjs.org/docs/app/getting-started/installation). Run `node --version` to check. If you're below that, update via [nodejs.org](https://nodejs.org) or use a version manager like `nvm`. ## Done-When - [ ] `http://localhost:3000` shows the furniture listing page - [ ] You can locate the `mockItems` array in `starter/app/page.tsx` - [ ] Both apps are running at the same time without port conflicts ## Solution The starter page component with mock data in place: ```tsx // starter/app/page.tsx const mockItems: Item[] = [ { id: 1, name: "Fernwood Sectional", category: "Seating", price: 2499, in_stock: true }, { id: 2, name: "Knotted Oak Coffee Table", category: "Tables", price: 849, in_stock: true }, { id: 3, name: "Garrison Bookshelf", category: "Storage", price: 629, in_stock: false }, { id: 4, name: "The Long Table", category: "Tables", price: 1199, in_stock: true }, { id: 5, name: "Pivot Desk Chair", category: "Seating", price: 449, in_stock: true }, { id: 6, name: "Ember Side Table", category: "Tables", price: 299, in_stock: true }, { id: 7, name: "Stacked Nightstand", category: "Storage", price: 389, in_stock: false }, { id: 8, name: "Canvas Floor Lamp", category: "Lighting", price: 219, in_stock: true }, ]; export default function Home() { return ( <>

All Furniture

{mockItems.map((item) => ...)}
); } ``` --- title: "Run with vercel dev" description: "Replace the two-terminal dev workflow with a single command that serves the Next.js frontend and FastAPI backend from the same origin. This is the setup that mirrors how Vercel runs both in production." canonical_url: "https://vercel.com/academy/python-on-vercel/run-with-vercel-dev" md_url: "https://vercel.com/academy/python-on-vercel/run-with-vercel-dev.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-20T18:12:27.123Z" content_type: "lesson" course: "python-on-vercel" course_title: "Python on Vercel" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Run with vercel dev # Run with vercel dev So far, Hazel Home requires two terminals and two URLs. That was useful while we inspected each app, but it does not resemble the Vercel deployment we are building. `vercel dev` reads the project the way Vercel does in production. It serves Next.js at `/` and sends `/api/*` requests to FastAPI, all from `http://localhost:3000`. We can develop against the same route structure that we plan to deploy. ## Outcome Run both apps together under `http://localhost:3000` using `vercel dev`, and confirm the FastAPI routes are reachable on the same origin as the Next.js app. ## Hands-on exercise 2.1 ### Stop the old dev servers Stop the terminals running `fastapi dev` and `npm run dev`. From this point on, one `vercel dev` process will serve the project. ### Link the project Current Vercel CLI guidance separates project linking from local development. From the `starter/` root, start the link flow: ```bash vercel link ``` The CLI asks which Vercel project this directory belongs to: ``` ? Set up and deploy "starter"? yes ? Which scope do you want to deploy to? Your Name ? Link to existing project? No ? What's your project's name? hazel-home ? In which directory is your code located? ./ ``` Choose your scope, create the `hazel-home` project, and keep `./` as the code directory. The CLI stores the link under `.vercel/`. Before running a project command, verify the resolved owner and project: ```bash vercel project inspect --non-interactive ``` Check that the output names the scope and `hazel-home` project you selected. If it points somewhere else, stop and rerun `vercel link` with the intended `--project` and `--scope` values. ### Run vercel dev Start the local Vercel development server: ```bash vercel dev ``` Vercel detects Next.js from `package.json` and installs the Python dependencies declared in `pyproject.toml`. When the local server is ready, the CLI prints: ``` > Ready! Available at http://localhost:3000 ``` \*\*Note: What vercel dev is doing\*\* The CLI reads your project structure the same way Vercel does at deploy time. Next.js runs as the main app on `/`. Anything under `/api/*` routes to your FastAPI app at `api/index.py`. Both come from one local server on port 3000. ### Confirm the frontend Open `http://localhost:3000`. Next.js still renders the Hazel Home inventory from mock data, now through the Vercel development server. ### Confirm the backend Open `http://localhost:3000/api/items`. The FastAPI JSON comes back: ```json [ {"id": 1, "name": "Fernwood Sectional", "category": "Seating", "price": 2499.0, "in_stock": true}, ... ] ``` The response now comes from `http://localhost:3000/api/items` instead of the standalone FastAPI server on port 8000. The `/api/items` path matches the production route we will use later. ### Trace the request We changed the development server rather than either application. A request now follows this path: ```text http://localhost:3000/api/items → api/index.py → FastAPI /api/items ``` The frontend and API share an origin, so browser requests between them do not require CORS configuration. Vercel will use the same path-based split when we deploy in Section 3. \*\*Note: What about CORS?\*\* A browser request to a backend on another origin would require that backend to return the appropriate CORS headers. Hazel Home serves both parts from one origin, so its architecture does not need that configuration. ## Try It With `vercel dev` running, both endpoints should be reachable through `localhost:3000`: ```bash curl http://localhost:3000/api ``` ```json {"message": "Hazel Home Furniture API"} ``` ```bash curl http://localhost:3000/api/items | head -c 100 ``` ```json [{"id":1,"name":"Fernwood Sectional","category":"Seating","price":2499.0,"in_stock":true} ``` Both responses should arrive through port 3000. ## Commit Running `vercel dev` creates local project metadata in `.vercel/`. The starter's `.gitignore` excludes that directory, so there is nothing to commit in this lesson. ## Troubleshooting **`vercel link` offers an unrelated existing project:** Choose "No" and create a fresh `hazel-home` project for the course. **Project inspection shows the wrong scope:** Run `vercel link --help`, then relink with explicit `--project` and `--scope` values. Inspect the project again before continuing. **`/api/items` returns 404 or "Not Found":** The FastAPI routes need to match the full path. Open `starter/api/index.py` and confirm the routes are defined as `@app.get("/api")` and `@app.get("/api/items")`, not just `/` and `/items`. **Port 3000 is already in use:** Another process (like a lingering `npm run dev`) is holding the port. Kill it with `lsof -ti:3000 | xargs kill` and try `vercel dev` again. **Python dependencies not installed:** If `/api/items` throws a Python import error, `vercel dev` didn't pick up `pyproject.toml`. Run `pip install "fastapi[standard]"` from `starter/` and restart `vercel dev`. ## Done-When - [ ] `vercel dev` runs without errors from `starter/` - [ ] `http://localhost:3000` loads the furniture listing page - [ ] `http://localhost:3000/api/items` returns the FastAPI JSON - [ ] Both work in the same browser tab with no CORS errors ## Solution ```bash cd starter vercel link vercel project inspect --non-interactive vercel dev ``` The starter already has the project structure that `vercel dev` expects. --- title: "Wire Next.js to FastAPI" description: "Convert the Next.js page component from a synchronous mock-data render to an async Server Component that fetches from the FastAPI API on the same origin, using Vercel's VERCEL_URL system variable in production and a localhost fallback during development." canonical_url: "https://vercel.com/academy/python-on-vercel/wire-nextjs-to-fastapi" md_url: "https://vercel.com/academy/python-on-vercel/wire-nextjs-to-fastapi.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-20T18:12:27.143Z" content_type: "lesson" course: "python-on-vercel" course_title: "Python on Vercel" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Wire Next.js to FastAPI # Wire Next.js to FastAPI The Hazel Home product grid has been showing the same eight items no matter what the API returns. We are ready to remove that disguise. The page is a Server Component, so its `fetch` runs in Node rather than in the browser. Node needs an absolute URL. We'll build one from Vercel's deployment hostname in production and use `localhost:3000` during local development. ## Outcome Replace the `mockItems` array with an async fetch to `/api/items` that works both under `vercel dev` locally and in production. ## Hands-on exercise 2.2 ### Build the request URL In a Client Component, the browser can resolve `fetch("/api/items")` against the current page. A Server Component runs without that browser context, and Node's `fetch` requires an absolute URL. When [system environment variables](https://vercel.com/docs/environment-variables/system-environment-variables) are exposed to a deployment, Vercel provides `VERCEL_URL` as the deployment hostname, such as `hazel-home-abc123.vercel.app`. Because the value has no protocol, the production branch adds `https://`. The local branch uses `http://localhost:3000`. ### Update the page component Open `starter/app/page.tsx` and replace the file with this: ```tsx type Item = { id: number; name: string; category: string; price: number; in_stock: boolean; }; async function getItems(): Promise { const base = process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : "http://localhost:3000"; const res = await fetch(`${base}/api/items`, { cache: "no-store" }); if (!res.ok) throw new Error("Failed to fetch items from Hazel Home API"); return res.json(); } export default async function Home() { const items = await getItems(); return ( <>

All Furniture

{items.map((item) => (

{item.category}

{item.name}

${item.price.toLocaleString()} {item.in_stock ? "In stock" : "Out of stock"}
))}
); } ``` The `mockItems` array is gone. `getItems()` builds an absolute URL, checks the response, and returns the decoded inventory. The [`no-store` option](https://nextjs.org/docs/app/api-reference/functions/fetch#optionscache) keeps this route request-time rendered, so `next build` does not try to contact a deployment that is still being built. Making `Home` asynchronous lets the component wait for the data before rendering the existing card markup. \*\*Note: No NEXT\_PUBLIC\_ prefix needed\*\* `VERCEL_URL` stays on the server because this request runs in a Server Component. Variables only need the `NEXT_PUBLIC_` prefix when browser code must read them. In Project Settings, the **Automatically expose System Environment Variables** option controls whether `VERCEL_URL` is available. ### Run vercel dev and refresh If `vercel dev` is still running from the last lesson, you should see the updated page after saving. If not, restart it: ```bash cd starter vercel dev ``` Open `http://localhost:3000`. The furniture listing appears, this time fetched live from the FastAPI server at `http://localhost:3000/api/items`. If you stop `vercel dev` and reload, Next.js reports a fetch error because the API is unavailable. That failure confirms the page now depends on FastAPI. ## Try It With `vercel dev` running, confirm the data is coming from FastAPI and not the mock: 1. Open `http://localhost:3000`. All 8 items appear. 2. Open `http://localhost:3000/api/items` in a second tab and compare the response. 3. Edit one item's name in `api/index.py`, save, and hard-refresh the frontend. The name updates. The edited product name is the useful test: the storefront is reading FastAPI's response rather than its old local array. ## Commit Save the connection between the storefront and API: ```bash git add app/page.tsx git commit -m "feat(storefront): fetch inventory from FastAPI" ``` ## Troubleshooting \*\*Note: Use the companion skill for quick checks\*\* If you're working in `academy-python-course`, ask your coding agent: "Use the `python-on-vercel` skill and check my `starter/app/page.tsx` and `starter/api/index.py` wiring." It can quickly spot route prefix mistakes, `VERCEL_URL` issues, and fetch URL problems. **`TypeError: Failed to parse URL` in the server logs:** This happens if `process.env.VERCEL_URL` is unset and the fallback didn't kick in. Confirm the ternary is written correctly and that the fallback value starts with `http://`. **`Failed to fetch items from Hazel Home API`:** `vercel dev` isn't running, or the FastAPI routes don't match. Check that `api/index.py` has `@app.get("/api/items")` (with the `/api` prefix) and that `vercel dev` is active on port 3000. ## Done-When - [ ] `page.tsx` uses `async function getItems()` with no `mockItems` array - [ ] The request uses `{ cache: "no-store" }` so the page renders at request time - [ ] The fetch URL resolves to `http://localhost:3000/api/items` in local dev - [ ] `http://localhost:3000` loads real data from FastAPI through `vercel dev` - [ ] Editing `api/index.py` and reloading updates what the frontend shows ## Solution ```tsx // starter/app/page.tsx type Item = { id: number; name: string; category: string; price: number; in_stock: boolean; }; async function getItems(): Promise { const base = process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : "http://localhost:3000"; const res = await fetch(`${base}/api/items`, { cache: "no-store" }); if (!res.ok) throw new Error("Failed to fetch items from Hazel Home API"); return res.json(); } export default async function Home() { const items = await getItems(); return ( <>

All Furniture

{items.map((item) => (

{item.category}

{item.name}

${item.price.toLocaleString()} {item.in_stock ? "In stock" : "Out of stock"}
))}
); } ``` --- title: "Deploy to Production" description: "Run vercel deploy at the project root, then verify the Next.js frontend and FastAPI backend under the production domain." canonical_url: "https://vercel.com/academy/python-on-vercel/deploy-to-prod" md_url: "https://vercel.com/academy/python-on-vercel/deploy-to-prod.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-20T18:12:27.186Z" content_type: "lesson" course: "python-on-vercel" course_title: "Python on Vercel" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Deploy to Production # Deploy to Production Hazel Home has spent long enough serving furniture only to `localhost`. Its project structure already describes the deployment: Next.js lives at the root, while FastAPI lives in `api/`. The dependency files for both runtimes sit at the project root, ready for Vercel. ## Outcome Deploy the combined project to Vercel with `vercel deploy --prod` and confirm both the Next.js frontend and the FastAPI backend are live under a single domain. ## Hands-on exercise 3.1 ### Deploy Open the project's Environment Variables settings in the Vercel dashboard and confirm that **Automatically expose System Environment Variables** is enabled. This makes `VERCEL_URL` available to the Server Component. Then, from the `starter/` root, deploy the project: ```bash vercel deploy --prod ``` The project is already linked from `vercel link` in Section 2, so the CLI skips the setup prompts and starts building. A few seconds later: ``` ✓ Deployed to production. https://hazel-home.vercel.app ``` ### What Vercel auto-detected During the deployment, Vercel builds the Next.js app from `package.json` and packages `api/index.py` as a Python function with the dependencies from `pyproject.toml`. Requests to `/` reach Next.js, while requests under `/api/*` reach FastAPI on the same domain. The directory layout provides the routing information, so this project does not need a `vercel.json` file. ### Verify the frontend Open the deployment URL in a browser: ``` https://hazel-home.vercel.app ``` The furniture listing should load with data from FastAPI. The Server Component reads the deployment hostname from `process.env.VERCEL_URL`, fetches `/api/items`, and renders the returned inventory. ### Verify the backend directly Hit the backend path: ``` https://hazel-home.vercel.app/api/items ``` FastAPI should return the inventory JSON that we saw in local development. ### Review the project boundary The shared project keeps the deployment boundary small: - The Python backend stays in the frontend's Vercel project - Same-origin requests do not require CORS configuration - The frontend does not need a separately managed backend hostname - Both builds and their logs belong to the same project That boundary is the main result of the course: the frontend and API can move through local development and deployment together. \*\*Note: Fluid compute\*\* FastAPI deploys as one Vercel Function with [Fluid compute](https://vercel.com/docs/fluid-compute) enabled by default. Fluid compute reuses function instances and supports concurrent requests, which reduces the frequency and effect of cold starts. \*\*Note: Other Python frameworks\*\* Flask can also expose a top-level application from `api/index.py`. Django projects use their framework's supported entrypoint and configuration, so consult the current Vercel Python documentation before adapting this layout. ## Try It Verify the full stack: ```bash curl https://hazel-home.vercel.app/api/items ``` ```json [{"id":1,"name":"Fernwood Sectional","category":"Seating","price":2499.0,"in_stock":true},...] ``` ```bash curl -s https://hazel-home.vercel.app | grep "Fernwood" ``` ``` Fernwood Sectional ``` The API request checks FastAPI directly. Finding `Fernwood` in the HTML confirms that Next.js received the inventory during server rendering. ## Commit Deployment does not change the source files. Make sure the commit from the previous lesson is present before you finish: ```bash git status --short ``` The command should return no output. ## Troubleshooting **Deploy succeeds but `/api/items` returns a 404:** The FastAPI routes don't include the `/api` prefix. Open `starter/api/index.py` and confirm the routes are `@app.get("/api")` and `@app.get("/api/items")`, not `/` and `/items`. **Deploy fails with "No module named fastapi":** `pyproject.toml` isn't at the project root or dependencies aren't declared. Check that `starter/pyproject.toml` exists and lists `fastapi` under `dependencies`. **Frontend loads but items don't appear:** Check the deployment logs for the failed request. If `process.env.VERCEL_URL` is undefined, enable **Automatically expose System Environment Variables** under Project Settings, then redeploy. **You want to clean up:** When you're done with the course, you can delete the `hazel-home` project from the Vercel dashboard to remove the deployment. ## Done-When - [ ] `vercel deploy --prod` succeeds from the `starter/` root - [ ] The project exposes Vercel system environment variables - [ ] `https://hazel-home.vercel.app` loads the furniture listing page - [ ] `https://hazel-home.vercel.app/api/items` returns the FastAPI JSON - [ ] Editing `api/index.py` and redeploying changes the inventory shown on the frontend ## Solution ```bash cd starter vercel deploy --prod ``` Hazel Home now serves its storefront and inventory API from the same Vercel project. --- title: "Creating a Software Factory" description: "Build a risk-routed software factory that requires evidence before code changes and preserves human control over consequential decisions." canonical_url: "https://vercel.com/academy/creating-a-software-factory" md_url: "https://vercel.com/academy/creating-a-software-factory.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-09-22T04:50:19.141Z" content_type: "course" lessons: 14 estimated_time: lesson_urls: - "https://vercel.com/academy/creating-a-software-factory/would-you-merge-this.md" - "https://vercel.com/academy/creating-a-software-factory/carry-the-receipts.md" - "https://vercel.com/academy/creating-a-software-factory/normalize-the-request.md" - "https://vercel.com/academy/creating-a-software-factory/classify-then-authorize.md" - "https://vercel.com/academy/creating-a-software-factory/test-the-stopping-rules.md" - "https://vercel.com/academy/creating-a-software-factory/reproduce-the-claim.md" - "https://vercel.com/academy/creating-a-software-factory/write-the-supported-spec.md" - "https://vercel.com/academy/creating-a-software-factory/challenge-the-premise.md" - "https://vercel.com/academy/creating-a-software-factory/build-within-bounds.md" - "https://vercel.com/academy/creating-a-software-factory/package-the-proof.md" - "https://vercel.com/academy/creating-a-software-factory/verify-in-fresh-context.md" - "https://vercel.com/academy/creating-a-software-factory/gate-by-consequence.md" - "https://vercel.com/academy/creating-a-software-factory/publish-a-draft.md" - "https://vercel.com/academy/creating-a-software-factory/learn-from-failure.md" --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Creating a Software Factory Agents can produce code quickly. Reviewers still need the request, proof that the problem exists, the real diff, passed checks, and any decision that belongs to a person. You'll build a factory that keeps those records in durable, risk-routed work orders. Low-risk changes can proceed, ambiguous requests ask for clarification, false premises stop, and public API changes wait for approval. The first payoff arrives before any service setup: you will trace a factory that refuses to fix a bug the repository proves does not exist. Live credentials and connector wiring wait until Section 3, when the factory needs them. ## What you'll build Our factory maintains a TypeScript notification SDK. Each GitHub issue becomes a durable work order that can: - Implement a supported change and prepare it for review - Ask a focused question when the request is incomplete - Stop when repository evidence contradicts the issue - Wait for human judgment before a consequential change AI SDK classifies the request. Deterministic TypeScript selects its route, and scoped tools enforce the boundary. eve preserves the run across model calls, sandboxes, redeploys, and approval pauses. The Investigator tests the issue's premise before code changes. The Builder works from an approved specification inside its own Vercel Sandbox. A Verifier checks the pushed branch in a fresh context and receives the real diff instead of the Builder's confidence. Public software-factory material often names the stages classifier, analyst, implementer, and reviewer. This course refines that sequence around risk and evidence: classification and routing stay at the root, while the Investigator, Builder, and Verifier receive isolated capabilities for the work they are allowed to perform. > **Want to learn more?** Explore [Foreman in Vercel's Agent Stack guide](https://vercel.com/kb/agent-stack), a production software factory built on eve, and read how Vercel built the [AI SDK software factory](https://vercel.com/blog/building-a-software-factory-for-ai-sdk). Both show these patterns at production scale while keeping human judgment at the center of consequential decisions. The factory may create a branch and open a draft pull request. Merge authority stays with the human reviewer. ## Four case files The course uses four issues to make the policy visible. **Uppercase channel names fail.** Reproduce the plausible bug, build from the supported spec, and verify the candidate. **Clarify webhook retries.** Ask for the missing behavior before editing. **Empty messages are delivered.** Record that the SDK already rejects them, then stop with zero changed lines. **Add delivery priority.** Wait for a person to accept the public API consequences. These outcomes describe the whole run. The implementation uses narrower words at each layer: | Course outcome | Root decision | Work-order status | Investigator disposition | | ---------------- | ------------- | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | | `fix` | `proceed` | `routed` → `investigating` → `building` → `verifying` → `ready-for-draft-pr` | `proceed` | | `clarify` | `clarify` | `needs-clarification` | `needs-clarification` when investigation reveals the gap; otherwise no Investigator runs | | `reject-premise` | `stop` | `stopped` | `unsupported` | | `human-judgment` | `wait` | `awaiting-approval` | `proceed`; the evidence supports the request, but policy still requires approval | The course outcome summarizes the run, the root decision controls the next action, status records progress, and disposition records what investigation learned. ## Course path - **The Trust Bottleneck, about 35 minutes.** Compare four outcomes and create a durable work order. - **Decide What May Proceed, about 50 minutes.** Classify each request and route it by risk. - **Prove the Request, about 60 minutes.** Connect the live services and reproduce the claim before changing code. - **Build Without Blind Trust, about 75 minutes.** Implement the specification and check the real diff. - **Operate Selective Autonomy, about 70 minutes.** Publish drafts and turn failures into evaluations. Plan on roughly five hours for the full course. The lessons use prediction callouts and observable checks instead of scored quizzes, so every checkpoint stays attached to the factory behavior you just built. ## What you'll need This course is for developers who use coding agents and know basic Git, GitHub, and command-line workflows. We introduce eve and AI SDK from the beginning. You will need: - Node.js 24 and pnpm - The latest Vercel CLI - A personal GitHub account with a repository you can connect - A Vercel Hobby account - Access to Vercel AI Gateway The course starts from the [Academy Software Factory repository](https://github.com/vercel-labs/academy-software-factory). Lesson 1.1 gives you a deploy button plus fork and clone instructions. Section 3 adds the environment values and GitHub connector immediately before the first live factory invocation. ## Cost and account scope Fixture traces and unit tests run locally. Live work uses AI Gateway tokens and, for investigation through verification, Vercel Sandboxes. Use routing-only evaluations while iterating and save three-sandbox bug runs for checkpoints. At the time of publication, AI Gateway's free tier includes [$5 per month in credit](https://vercel.com/docs/ai-gateway/pricing), while a Vercel Hobby account includes [five active Sandbox CPU-hours per month](https://vercel.com/pricing) and [5,000 Vercel Connect token requests per month](https://vercel.com/kb/guide/vercel-connect). Purchasing AI Gateway credits moves that account to the paid tier and ends the monthly free credit. The expected out-of-pocket cost is $0 only when those allowances are unused and you complete a careful single pass. Model choice, retries, prior account usage, and future pricing can make it higher. Check the linked pricing pages and your Vercel Usage dashboard before full evaluations. Lesson 5.3 includes a complete teardown for the deployment, trigger forwarding, connector, GitHub label, branches, and local credentials. Lesson 1 begins by deciding which requests should be allowed to proceed. --- title: "Would You Merge This?" description: "Inspect four recorded runs, trace the outcomes a trustworthy factory needs, and prepare the local starter repository." canonical_url: "https://vercel.com/academy/creating-a-software-factory/would-you-merge-this" md_url: "https://vercel.com/academy/creating-a-software-factory/would-you-merge-this.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-29T01:48:00.491Z" content_type: "lesson" course: "creating-a-software-factory" course_title: "Creating a Software Factory" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Would You Merge This? # Would you merge this? Four agent-authored changes reach the review queue together. You still have to decide what is safe. ```text Uppercase channel names fail → fix Clarify webhook retries → ask a question Empty messages are delivered → stop Add delivery priority → wait for a person ``` Not every request should become a pull request. The next action depends on the available evidence and the uncertainty that remains. ## Give the Factory Four Honest Exits Build a command that traces four issues through four different factory outcomes. ## Hands-on Exercise 1.1 ### Create your course repository The [course starter](https://github.com/vercel-labs/academy-software-factory) has two branches: - `main` is the starter branch you will modify throughout the course. - `solution` contains the completed project for reference. Complete the exercises on `main`. Two repositories appear in the workflow: | Repository | Purpose | | -------------------------------------- | ---------------------------------------------------------------------------------------------------------- | | `vercel-labs/academy-software-factory` | The upstream course template and reference branches. | | Your personal fork | The repository the factory clones, changes on `factory/*` branches, and opens draft pull requests against. | Create your GitHub copy and Vercel project: [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel-labs%2Facademy-software-factory\&project-name=signalworks-software-factory\&repository-name=signalworks-software-factory) The button creates a repository in your GitHub account and connects it to a Vercel project. If you prefer to fork manually, fork the starter on GitHub and import that fork into a new Vercel project. Clone **your copy**, then confirm that it is on `main`: ```bash git clone https://github.com/YOUR_GITHUB_NAME/signalworks-software-factory.git cd signalworks-software-factory git switch main pnpm install ``` The project starts small: ```text agent/ # eve agent, channels, policies, and stations fixtures/issues/ # prompts for the four course cases fixtures/runs/ # recorded outcomes used before live model calls packages/notification-sdk/ # sample TypeScript product the factory maintains evals/ # regression evaluations added in Section 5 ``` ### Trace the four outcomes Read each issue before its recording. Predict where it should finish and what evidence would change your answer. Now add the trace command to `package.json`: ```json title="package.json" { "scripts": { "trace": "node scripts/trace-work-order.mjs" } } ``` Keep the existing scripts. The shortened object shows the new entry only. Create `scripts/trace-work-order.mjs`. Load every JSON recording instead of hard-coding one issue: ```js title="scripts/trace-work-order.mjs" import { readdir, readFile } from "node:fs/promises"; const runsUrl = new URL("../fixtures/runs/", import.meta.url); const files = (await readdir(runsUrl)) .filter((file) => file.endsWith(".json")) .sort(); for (const file of files) { const run = JSON.parse(await readFile(new URL(file, runsUrl), "utf8")); console.log(`${run.workOrderId}: ${run.issue.title} [${run.outcome}]`); for (const [index, event] of run.events.entries()) { const step = String(index + 1).padStart(2, "0"); const station = event.station.toUpperCase().padEnd(12); console.log(`${step} ${station} ${event.status}`); console.log(` ${event.summary}`); } console.log(); } ``` `import.meta.url` keeps the fixture path relative to the script. Before running it, inspect `issue-44.json`. Routing accepts a plausible bug; investigation disproves it. Run the trace now: ```bash pnpm trace ``` The output should show four outcomes: ```text issue-42: Uppercase channel names fail [fix] issue-43: Clarify webhook retries [clarify] issue-44: Empty messages are delivered [reject-premise] issue-45: Add delivery priority [human-judgment] ``` Find the earliest divergence. The unclear request stops at routing, the false premise reaches investigation, and the public API request waits after specification. \*\*Note: Move one boundary\*\* Change issue 45's final status from `awaiting-approval` to `building`, then run the trace again. What decision has the factory silently taken away from the reviewer? Restore the recording when you finish. \*\*Warning: Only one issue appears\*\* Read the directory with `readdir(runsUrl)` and loop over every `.json` file. A path to `issue-42.json` preserves the old single-case version. \*\*Warning: The trace order changes\*\* Call `.sort()` after filtering the filenames so the trace order is deterministic. ## Try It Verify the local project: ```bash pnpm validate ``` `pnpm validate` should finish with zero diagnostics. Section 3 connects the services immediately before the first live invocation. ## Commit ```bash git add package.json scripts/trace-work-order.mjs git commit -m "feat(factory): trace four outcomes" ``` ## Done-When - [ ] `pnpm trace` prints all four work orders - [ ] The unclear request stops before investigation - [ ] The false premise stops after repository evidence arrives - [ ] The public API request pauses before implementation - [ ] The recordings show only independently verified work reaching draft pull request approval - [ ] `pnpm validate` reports zero diagnostics The first factory success changed zero files. Now we can give every future decision a durable record. ## Solution The exercise contains the complete `package.json` entry and `scripts/trace-work-order.mjs`. Your result should match the four headings in **Trace the four outcomes**. --- title: "Carry the Receipts" description: "Define a typed work order, append observable evidence with eve tools, and configure a durable root session that can resume without losing the factory's decision trail." canonical_url: "https://vercel.com/academy/creating-a-software-factory/carry-the-receipts" md_url: "https://vercel.com/academy/creating-a-software-factory/carry-the-receipts.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-29T01:48:00.510Z" content_type: "lesson" course: "creating-a-software-factory" course_title: "Creating a Software Factory" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Carry the Receipts # Carry the receipts Picture a run that pauses Friday afternoon and resumes after Monday's deploy. If its request, commands, and decisions lived only in the old process, the reviewer gets a confident answer with no usable history. ```ts title="agent/lib/work-order.ts" export const evidenceSchema = z.object({ details: z.string().optional(), kind: z.enum(["observation", "command", "test", "decision", "diff"]), recordedAt: z.iso.datetime(), summary: z.string().min(1), }); ``` A work order gives every station the same request, current status, route, and evidence. eve keeps that object attached to a durable session across model calls, sandboxes, redeploys, and approval pauses. ## Carry Evidence Across Every Pause Create a durable work-order contract with tools that initialize work and append observable evidence. ## Hands-on Exercise 1.2 Create `agent/lib/work-order.ts`. Define these accepted values first: ```ts title="agent/lib/work-order.ts" import { z } from "zod"; export const workTypeSchema = z.enum([ "documentation", "bug", "public-api", "unknown", ]); export const riskSchema = z.enum(["low", "medium", "high"]); export const laneSchema = z.enum([ "documentation", "bug", "public-api", "manual", ]); ``` Add `evidenceSchema` from the opening, then define `workOrderSchema`. Its source is always present. Classification and route are optional because intake has not made those decisions yet. ```ts title="agent/lib/work-order.ts" export const workOrderSchema = z.object({ classification: z.object({ actionable: z.boolean(), confidence: z.number().min(0).max(1), questions: z.array(z.string()), rationale: z.string(), risk: riskSchema, type: workTypeSchema, }).optional(), evidence: z.array(evidenceSchema).default([]), id: z.string().min(1), route: z.object({ approvalRequired: z.boolean(), lane: laneSchema, reason: z.string(), }).optional(), source: z.object({ body: z.string(), number: z.number().int().positive(), title: z.string().min(1), url: z.url(), }), status: z.enum([ "received", "needs-clarification", "routed", "investigating", "awaiting-approval", "building", "verifying", "ready-for-draft-pr", "stopped", ]), }); ``` Infer `Evidence` and `WorkOrder` types from the schemas. Add an `addEvidence` function that creates the timestamp and parses the updated object. Create the timestamp inside `addEvidence` rather than accepting it from tool input. Create `agent/tools/create_work_order.ts` and `agent/tools/record_evidence.ts`. Each file becomes an eve tool, so their filenames become the tool names. ```ts title="agent/tools/create_work_order.ts" import { defineTool } from "eve/tools"; import { z } from "zod"; import { workOrderSchema } from "../lib/work-order.js"; export default defineTool({ description: "Create the typed work order for one GitHub issue.", execute(input) { return workOrderSchema.parse({ evidence: [], id: `issue-${input.number}`, source: input, status: "received", }); }, inputSchema: z.object({ body: z.string(), number: z.number().int().positive(), title: z.string().min(1), url: z.url(), }), }); ``` `record_evidence` accepts the current work order and one evidence record without `recordedAt`. It returns the entire updated work order. Finally, configure enough session time and output budget for investigation, verification, and approval pauses. In `agent/agent.ts`, keep `maxOutputTokensPerSession: 80_000` and add `sessionTimeoutMs`: ```ts title="agent/agent.ts" limits: { maxOutputTokensPerSession: 80_000, sessionTimeoutMs: 7 * 24 * 60 * 60 * 1_000, }, ``` The output-token limit is a ceiling for the whole durable session, including subagents and revision loops. It prevents an accidental unbounded run; it is not a target and does not reserve or spend 80,000 tokens by itself. Disable eve's general delegation tool at `agent/tools/agent.ts`: ```ts title="agent/tools/agent.ts" import { disableTool } from "eve/tools"; export default disableTool(); ``` Set the session timeout to seven days. General delegation stays off until we add named specialists with narrow jobs. ## Try It Create `agent/lib/work-order.test.ts` with one new work order and one appended observation. Then run: ```bash pnpm test agent/lib/work-order.test.ts pnpm typecheck pnpm exec eve info ``` The test should report two passing checks. eve should discover two authored tools and zero subagents: ```text Compile ready Diagnostics 0 errors, 0 warnings Tools 2 tools Subagents 0 subagents ``` \*\*Note: Inspect the trust boundary\*\* Try to create a work order with `status: "looks-good-to-me"`. Zod should reject any state outside the defined workflow. \*\*Warning: record\_evidence requires a timestamp\*\* Use `evidenceSchema.omit({ recordedAt: true })` in the tool input. `addEvidence` creates the timestamp during execution. \*\*Warning: eve exposes a general agent tool\*\* The override must live at `agent/tools/agent.ts` and export `disableTool()` as its default value. ## Commit ```bash git add agent git commit -m "feat(factory): preserve durable evidence" ``` ## Done-When - [ ] Every work order carries its source, status, route, and evidence - [ ] New work orders begin with an empty evidence list - [ ] Evidence timestamps are created by the factory - [ ] Root sessions have a seven-day lifetime - [ ] `eve info` reports two tools and zero diagnostics The work order can now outlive the process that created it. Only trusted GitHub events should be allowed to create one. ## Solution The exercise contains the complete schemas, tool shape, and session limits. Compare `agent/lib/work-order.ts`, `agent/tools/record_evidence.ts`, and `agent/agent.ts` with the `solution` branch if a focused test fails. --- title: "Normalize the Request" description: "Validate incoming GitHub issue data, normalize missing bodies, and attach one stable source issue to the durable eve session after the channel's trust checks pass." canonical_url: "https://vercel.com/academy/creating-a-software-factory/normalize-the-request" md_url: "https://vercel.com/academy/creating-a-software-factory/normalize-the-request.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-29T01:48:00.550Z" content_type: "lesson" course: "creating-a-software-factory" course_title: "Creating a Software Factory" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Normalize the Request # Normalize the request GitHub arrives carrying a suitcase of nested fields. The factory needs four of them. ```ts { body: string, number: number, title: string, url: string, } ``` Normalization creates a stable boundary between somebody else's webhook shape and every decision we make afterward. ## Shrink the Webhook at the Door Convert trusted GitHub issue events into validated source issues for durable work orders. ## Hands-on Exercise 2.1 Create `agent/lib/intake.ts`. Validate the raw fields we consume and accept GitHub's two versions of an empty body, `null` and missing: ```ts title="agent/lib/intake.ts" import type { GitHubIssueEvent } from "eve/channels/github"; import { z } from "zod"; const rawIssueSchema = z.object({ body: z.string().nullable().optional(), html_url: z.url(), title: z.string().min(1), }); export function normalizeIssue(issue: GitHubIssueEvent) { const raw = rawIssueSchema.parse(issue.raw); return { body: raw.body ?? "", number: issue.issueNumber, title: raw.title, url: raw.html_url, }; } ``` The nullish coalescing operator preserves a real body and turns either empty representation into `""`. Downstream tools now receive one shape. Create `agent/lib/intake.test.ts` with a labeled issue event. Assert the exact normalized object, including the issue number supplied by eve rather than the raw payload. Now open `agent/channels/github.ts`. Keep its existing checks for the factory label, bot senders, and trusted maintainer roles. After those checks pass, normalize the issue and place it in the returned context: ```ts title="agent/channels/github.ts" const sourceIssue = normalizeIssue(issue); return { auth: defaultGitHubAuth(ctx), context: [ intakeTask, `Create the work order from this normalized source issue:\n${JSON.stringify(sourceIssue)}`, ], }; ``` This order matters. Invalid or untrusted events return `null` before they can start durable work or spend model tokens. ## Try It Run the focused test, then inspect the compiled channel: ```bash pnpm test agent/lib/intake.test.ts pnpm typecheck pnpm exec eve info ``` The focused suite reports one passing test. The application still has two root tools because normalization is ordinary TypeScript, not a model-facing capability. \*\*Note: Send an empty issue body\*\* Add a second test with `body: null`. The normalized result should contain `body: ""`. The request will later become a clarification outcome instead of crashing intake. \*\*Warning: Zod rejects a missing body\*\* Keep both `.nullable()` and `.optional()`. GitHub may send either representation. \*\*Warning: Untrusted events start sessions\*\* Call `normalizeIssue` only after the channel's label, sender, and permission checks return successfully. ## Commit ```bash git add agent/lib/intake.ts agent/lib/intake.test.ts agent/channels/github.ts git commit -m "feat(factory): normalize trusted issues" ``` ## Done-When - [ ] Intake returns one stable source shape - [ ] Missing and null bodies become empty strings - [ ] Invalid URLs fail before entering a work order - [ ] Untrusted events return before normalization - [ ] The intake test and typecheck pass The webhook is now somebody else's shape at the boundary and our stable contract everywhere else. ## Solution The exercise shows the complete `agent/lib/intake.ts` and channel integration. The finished boundary returns only `body`, `number`, `title`, and `url`, with an absent or `null` body normalized to an empty string. --- title: "Classify, Then Authorize" description: "Generate a structured issue classification with AI SDK, then use deterministic TypeScript to select its lane and approval requirement before later capability gates enforce consequential boundaries." canonical_url: "https://vercel.com/academy/creating-a-software-factory/classify-then-authorize" md_url: "https://vercel.com/academy/creating-a-software-factory/classify-then-authorize.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-29T01:48:00.567Z" content_type: "lesson" course: "creating-a-software-factory" course_title: "Creating a Software Factory" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Classify, Then Authorize # Classify, then authorize A model can correctly classify “Add delivery priority” as a high-risk public API change. That classification should not authorize implementation. ```text Model: What kind of request is this? Policy: What is this request allowed to do next? ``` The model describes ambiguous language. Deterministic TypeScript selects the route. The root orchestrator follows that result, and later lessons enforce consequential boundaries around approval and repository writes. ## Let Policy Hold the Keys Classify an issue with AI SDK and route the validated result with deterministic policy. ## Hands-on Exercise 2.2 Create `agent/lib/classification.ts` with the model's output contract: ```ts title="agent/lib/classification.ts" import { z } from "zod"; import { riskSchema, workTypeSchema } from "./work-order.js"; export const classificationSchema = z.object({ actionable: z.boolean(), confidence: z.number().min(0).max(1), questions: z.array(z.string().min(1)), rationale: z.string().min(1), risk: riskSchema, type: workTypeSchema, }); export type Classification = z.infer; ``` Add a router model to `agent/lib/models.ts`, then create `agent/tools/classify_issue.ts`. Use `generateText` with `Output.object()` so AI SDK validates the model's answer before the factory sees it. ```ts title="agent/tools/classify_issue.ts" const result = await generateText({ model: MODELS.router, output: Output.object({ schema: classificationSchema }), prompt: `Title: ${input.title}\n\n${input.body}`, system: [ "Classify work for a TypeScript notification SDK.", "Use documentation for prose-only changes, bug for incorrect existing behavior, and public-api for exported contract changes.", "Mark work actionable only when an engineer can define a testable outcome without inventing requirements.", "Use high risk for exported API changes, security-sensitive work, or possible breaking changes.", "Questions must be empty when the request is actionable.", ].join(" "), }); return result.output; ``` Now create `agent/lib/routing.ts`. Handle unclear work first, then public API changes, documentation, and bugs: ```ts title="agent/lib/routing.ts" export function routeClassification(classification: Classification): WorkRoute { if (!classification.actionable || classification.type === "unknown") { return { approvalRequired: true, lane: "manual", reason: "The request needs clarification before the factory can act.", }; } if (classification.type === "public-api") { return { approvalRequired: true, lane: "public-api", reason: "Exported API changes require an approved specification.", }; } // Implement the documentation and bug branches here. } ``` Implement both remaining branches. Documentation uses the `documentation` lane and low- or medium-risk prose can take the short lane. Bugs use the `bug` lane and must be reproduced before implementation. In either lane, `high` risk sets `approvalRequired` to `true`; lower risks set it to `false`. Give each result a reason that explains both its lane and approval decision. Expose the pure router through `agent/tools/route_work_order.ts`. The tool input is `classificationSchema`, so malformed model output cannot reach policy. Add the first procedure to `agent/instructions.md`: create a work order, classify it, route it, and stop with focused questions when the manual lane is selected. ## Try It Compile the application: ```bash pnpm typecheck pnpm exec eve info ``` The manifest should now report four root tools: ```text Compile ready Diagnostics 0 errors, 0 warnings Tools 4 tools Subagents 0 subagents ``` Before the next lesson writes tests, predict these routes: ```text Webhook docs are confusing → manual Uppercase channel names fail → bug Add optional exported priority → public-api + approval Fix one clear sentence in the README → documentation ``` \*\*Note: Give confidence too much power\*\* Temporarily route any classification above `0.95` without approval. A highly confident public API classification now bypasses the gate. Confidence describes the model's answer; it grants no authority. \*\*Warning: AI SDK returns text\*\* Pass `Output.object({ schema: classificationSchema })`. Asking for JSON in prose does not create a typed boundary. \*\*Warning: The route changes between runs\*\* Keep routing in `routeClassification`. If the model writes the route directly, policy becomes probabilistic. ## Commit ```bash git add agent git commit -m "feat(factory): classify and authorize work" ``` ## Done-When - [ ] AI SDK returns a validated classification object - [ ] Confidence is bounded between zero and one - [ ] Unclear work reaches the manual lane - [ ] Public API work always requires approval - [ ] The deterministic router produces every lane and approval requirement The model describes the request. Deterministic routing selects the lane and approval requirement, and later capability gates enforce those decisions. ## Solution The four route branches and complete tool wrapper appear in the exercise. Compare `agent/lib/routing.ts` and `agent/tools/route_work_order.ts` with the `solution` branch if your manifest or predictions differ. --- title: "Test the Stopping Rules" description: "Strengthen the classification contract, test all four deterministic routes, and verify that unclear work stops before any repository specialist receives the request." canonical_url: "https://vercel.com/academy/creating-a-software-factory/test-the-stopping-rules" md_url: "https://vercel.com/academy/creating-a-software-factory/test-the-stopping-rules.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-29T01:48:00.587Z" content_type: "lesson" course: "creating-a-software-factory" course_title: "Creating a Software Factory" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Test the Stopping Rules # Test the stopping rules Open the routing suite and ask a less glamorous question: which requests are guaranteed to go nowhere? Shipping has a demo. Stopping needs tests. ```ts expect(routeClassification(unclear)).toMatchObject({ lane: "manual" }); expect(routeClassification(publicApi)).toMatchObject({ lane: "public-api", approvalRequired: true, }); ``` ## Make Stopping a Tested Behavior Protect clarification, short-lane, investigation, and approval decisions with deterministic tests. ## Hands-on Exercise 2.3 Start with a relationship the basic schema cannot express. An unclear classification needs at least one useful question, while actionable work must not carry clarification questions. Extend `classificationSchema` with `superRefine`: ```ts title="agent/lib/classification.ts" .superRefine((classification, ctx) => { if (!classification.actionable && classification.questions.length === 0) { ctx.addIssue({ code: "custom", message: "Unclear work must include at least one focused question.", path: ["questions"], }); } if (classification.actionable && classification.questions.length > 0) { ctx.addIssue({ code: "custom", message: "Actionable work cannot include clarification questions.", path: ["questions"], }); } }); ``` Add both failures to `agent/lib/classification.test.ts`. We want malformed decisions to fail before policy receives them. Now create `agent/lib/routing.test.ts`. Give each case a reason to exist: ```ts title="agent/lib/routing.test.ts" it("asks what the webhook docs get wrong", () => { expect(routeClassification({ actionable: false, confidence: 0.4, questions: ["Which retry behavior should the README describe?"], rationale: "The requested documentation outcome is missing.", risk: "low", type: "documentation", })).toMatchObject({ approvalRequired: true, lane: "manual" }); }); ``` Add cases for a low-risk documentation correction, the uppercase-channel bug, and exported delivery priority. The false empty-message report still belongs in the bug lane here because the repository evidence has not been checked yet. That distinction matters. Routing decides whether the selected lane requires investigation. Investigation determines whether repository evidence supports the claim. Update `agent/instructions.md` so a manual route returns focused questions and stops before any subagent is called. ## Try It Run the full deterministic suite: ```bash pnpm test pnpm typecheck ``` The routing file should report four passing cases: ```text ✓ agent/lib/routing.test.ts (4 tests) ``` Then read each test name without looking at its input. The names should tell a reviewer which authority boundary broke. \*\*Note: Break the public API gate\*\* Change the public API route to `approvalRequired: false`. One targeted test should fail. If several unrelated tests fail, the suite is describing implementation details instead of policy. \*\*Warning: The false report stops at routing\*\* A plausible bug should reach investigation. The factory cannot reject a repository claim before inspecting the repository. \*\*Warning: Unclear work has no question\*\* Return a focused question in `questions`. A manual lane without a useful response turns safety into a dead end. ## Commit ```bash git add agent/lib agent/instructions.md git commit -m "test(factory): protect stopping rules" ``` ## Done-When - [ ] Unclear classifications contain a focused question - [ ] Actionable classifications contain no questions - [ ] Low-risk documentation can use the short lane - [ ] Plausible bugs reach investigation - [ ] Public API changes require approval The route tests define which requests may continue. The repository gets the next vote. ## Solution The completed route suite has the four cases listed in **Done-When**. Compare `agent/lib/classification.test.ts` and `agent/lib/routing.test.ts` with the `solution` branch if a boundary does not fail independently. --- title: "Reproduce the Claim" description: "Create an isolated Investigator, give it read-only repository access, and require the smallest disposable probe that can support or contradict a bug report." canonical_url: "https://vercel.com/academy/creating-a-software-factory/reproduce-the-claim" md_url: "https://vercel.com/academy/creating-a-software-factory/reproduce-the-claim.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-29T01:48:00.621Z" content_type: "lesson" course: "creating-a-software-factory" course_title: "Creating a Software Factory" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Reproduce the Claim # Reproduce the claim “Empty messages are delivered” sounds specific enough to fix. The title names a behavior, and the body asks for a regression test. But the issue is still a claim, not evidence that the bug exists. ```text Issue claim → smallest probe → observed result ``` The Investigator tests the premise against the repository before writing a specification. ## Make the Issue Prove Itself Create a read-only Investigator that reproduces or contradicts a reported bug inside Vercel Sandbox. ## Hands-on Exercise 3.1 ### Connect the live services The first two sections stayed local. Live investigation needs a repository connector and Vercel Sandboxes. Link the directory. `eve link` creates or selects the Vercel project and pulls local OIDC credentials used by AI Gateway and Vercel Connect: ```bash pnpm exec eve link ``` Create a managed GitHub connector, install its GitHub App on **your course repository only**, and attach it to the linked project: ```bash vercel connect create github --name signalworks-factory --triggers vercel connect attach github/signalworks-factory --triggers --trigger-path /eve/v1/github vercel connect list ``` If `vercel connect` is unavailable, update the CLI. The connector supplies short-lived installation tokens; do not create a personal access token or GitHub App key. Add the course configuration to the linked project, then pull it locally: ```bash vercel env add FACTORY_REPO --value YOUR_GITHUB_NAME/signalworks-software-factory vercel env add FACTORY_LABEL --value factory vercel env add FACTORY_BRANCH_PREFIX --value factory/ vercel env add FACTORY_SETUP_COMMAND --value "pnpm install --frozen-lockfile" vercel env add GITHUB_CONNECTOR --value github/signalworks-factory vercel env pull .env.local ``` Confirm the five values. `GITHUB_CONNECTOR` must match `vercel connect list`, and `FACTORY_REPO` must name the connected personal repository. Refresh expired authentication with `vercel env pull .env.local`. \*\*Warning: Keep credentials out of Git\*\* `.env.local` is ignored by the starter. Never commit OIDC tokens, AI Gateway keys, GitHub installation tokens, or connector credentials. ### Build the Investigator Open `agent/lib/config.ts` and set the fallback repository to your personal GitHub fork. Vercel Hobby projects use repositories owned by your personal account. Create `agent/subagents/investigator/sandbox.ts`: ```ts title="agent/subagents/investigator/sandbox.ts" import { defineSandbox } from "eve/sandbox"; import { vercel } from "eve/sandbox/vercel"; import { repoBootstrap, repoOnSession, repoRevalidationKey, } from "../../lib/github/repo-sandbox.js"; export default defineSandbox({ backend: vercel(), bootstrap: repoBootstrap, onSession: repoOnSession, revalidationKey: repoRevalidationKey, }); ``` The sandbox clones into `/workspace/repo`, isolated from the host filesystem. Create `agent/subagents/investigator/agent.ts` with a small first contract: ```ts title="agent/subagents/investigator/agent.ts" import { defineAgent } from "eve"; import { MODELS } from "../../lib/models.js"; export default defineAgent({ description: "Investigate a reported behavior against the real repository. Reproduce the claim and return command-backed evidence. Never modify files.", model: MODELS.investigator, outputSchema: { additionalProperties: false, properties: { claimSupported: { type: "boolean" }, evidence: { items: { additionalProperties: false, properties: { command: { type: ["string", "null"] }, result: { type: "string" }, summary: { type: "string" }, }, required: ["summary", "command", "result"], type: "object", }, minItems: 1, type: "array", }, }, required: ["claimSupported", "evidence"], type: "object", }, }); ``` Add `agent/subagents/investigator/instructions.md`. Require the smallest disposable probe that can decide the claim, relevant output for every command, and cleanup before returning. The Investigator never edits product files. Finally, add the bug-lane delegation to `agent/instructions.md`. Pass the complete work order because specialists do not inherit the root conversation. ## Try It Check discovery first: ```bash pnpm typecheck pnpm exec eve info ``` The application should report one subagent. Then invoke the factory with the uppercase-channel fixture. This is the course's canonical non-interactive invocation command: ```bash pnpm exec eve invoke "$(cat fixtures/issues/bug-example.md)" ``` `eve invoke` runs without the TUI using the linked environment. For an interactive trace, run `pnpm exec eve dev` and paste the fixture into the TUI. The Investigator's result must include a command that demonstrates `SLACK` failing before the fix. A summary without a command is another claim. Now repeat with the false-premise fixture: ```bash pnpm exec eve invoke "$(cat fixtures/issues/false-premise-example.md)" ``` Both reports may enter the bug lane, but their `claimSupported` values should differ after the probes run. \*\*Note: Predict the cheapest probe\*\* Before invoking either issue, inspect the notification SDK tests. Write the smallest command that could distinguish a real failure from a false premise. Compare it with the Investigator's choice. \*\*Warning: The Investigator edits a test\*\* Disposable probes may be created and removed, but the final repository must remain unchanged. Reproduction is read-only factory work. \*\*Warning: The sandbox cannot clone the repository\*\* Confirm the repository uses `owner/name` format and belongs to the personal GitHub account connected to the Vercel Hobby project. ## Commit ```bash git add agent/subagents/investigator agent/instructions.md agent/lib/config.ts git commit -m "feat(factory): reproduce issue claims" ``` ## Done-When - [ ] eve discovers one Investigator subagent - [ ] The linked project has an attached GitHub connector and the five course values - [ ] The Investigator works inside `/workspace/repo` - [ ] Every conclusion contains command-backed evidence - [ ] Disposable probes are removed before the result returns - [ ] The real bug and false premise produce different support decisions One report now fails before any Builder sees it. Supported observations can move on to a specification. ## Solution `agent/subagents/investigator/instructions.md` contains: ```md title="agent/subagents/investigator/instructions.md" # Investigator You investigate one work order against the repository at `/workspace/repo`. You never edit product files. Read the source issue, classification, route, and existing evidence from the delegation message. For a bug, create and run the smallest disposable probe that proves or disproves the report. Remove the probe before finishing. Return evidence for every conclusion. Commands include their relevant output. Observations name the file and behavior inspected. Do not claim that a bug exists without a reproducing result. ``` The root instructions delegate only the bug and public API lanes to `investigator`, passing the complete current work order. --- title: "Write the Supported Spec" description: "Extend the Investigator to inspect relevant architecture and return a problem statement, affected files, risks, acceptance criteria, and test strategy supported by repository evidence." canonical_url: "https://vercel.com/academy/creating-a-software-factory/write-the-supported-spec" md_url: "https://vercel.com/academy/creating-a-software-factory/write-the-supported-spec.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-29T01:48:00.645Z" content_type: "lesson" course: "creating-a-software-factory" course_title: "Creating a Software Factory" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Write the Supported Spec # Write the supported spec Reproducing the uppercase-channel failure is not yet an implementation contract. The Builder needs a precise boundary it can test. ```json { "problemStatement": "Provider lookup rejects uppercase channel configuration.", "acceptanceCriteria": [ "SLACK selects the Slack provider", "Existing lowercase callers keep working" ] } ``` A supported specification describes observable behavior and the evidence behind it. It does not prescribe a favorite helper name before reading the code. ## Give the Builder a Supported Spec Extend the Investigator to return an evidence-backed specification with behavioral acceptance criteria. ## Hands-on Exercise 3.2 Open `agent/subagents/investigator/agent.ts`. Add these properties to `outputSchema.properties`: ```ts title="agent/subagents/investigator/agent.ts" affectedFiles: { items: { type: "string" }, type: "array" }, approach: { type: "string" }, problemStatement: { type: "string" }, risks: { items: { type: "string" }, type: "array" }, acceptanceCriteria: { items: { type: "string" }, minItems: 1, type: "array", }, testStrategy: { type: "string" }, ``` Add all six names to `required`. An empty risk array is valid, but the Investigator must make that assessment explicitly. Update `agent/subagents/investigator/instructions.md` with an inspection procedure: ```md title="agent/subagents/investigator/instructions.md" Start with the package scripts and exported entry point, then read the nearest implementation and tests connected to the reproduced behavior. Keep the affected file list narrow. Write acceptance criteria as observable behavior. Name the exact test command and the new or existing case that will prove each criterion. Record compatibility risks separately from the implementation approach. ``` This procedure keeps exploration tied to the claim. Broad repository exploration increases context use and encourages unrelated changes. For the public API case, require the Investigator to inspect the exported `Notification` contract and existing callers. Its specification should describe backward compatibility before a person sees the approval request. ## Try It Run the authored checks: ```bash pnpm typecheck pnpm exec eve info ``` Invoke the uppercase-channel issue again: ```bash pnpm exec eve invoke "$(cat fixtures/issues/bug-example.md)" ``` Inspect the structured result and ask: - Does every affected file connect to the reproduced behavior? - Can each acceptance criterion pass or fail without reading the approach? - Does the test strategy name a real repository command? - Are compatibility risks explicit? The result should remain useful if a different Builder implements it tomorrow. \*\*Note: Swap the implementation\*\* Imagine the Builder normalizes the channel at a different layer. If the acceptance criteria still make sense, they describe behavior. If they fail because a helper has the wrong name, the spec has become a code recipe. \*\*Warning: The affected list contains the repository\*\* Require each path to connect to the reproduced behavior. Broad scope gives the Builder permission the investigation did not earn. \*\*Warning: The test strategy says run tests\*\* Name the command and the case it must cover. “Run tests” is not a reproducible test strategy. ## Commit ```bash git add agent/subagents/investigator git commit -m "feat(factory): write supported specifications" ``` ## Done-When - [ ] The problem statement matches the reproduced behavior - [ ] Affected files remain connected to the claim - [ ] Acceptance criteria describe observable behavior - [ ] The test strategy names real commands and cases - [ ] Public API specifications include compatibility risks The Builder now has a specification supported by repository evidence. Some investigations should never produce one. ## Solution The complete property definitions and required-field list appear in the exercise. Compare `agent/subagents/investigator/agent.ts` with the `solution` branch if structured output rejects the result. --- title: "Challenge the Premise" description: "Add explicit investigation dispositions and stopping rules so unsupported claims and missing product decisions cannot drift into implementation." canonical_url: "https://vercel.com/academy/creating-a-software-factory/challenge-the-premise" md_url: "https://vercel.com/academy/creating-a-software-factory/challenge-the-premise.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-29T01:48:00.666Z" content_type: "lesson" course: "creating-a-software-factory" course_title: "Creating a Software Factory" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Challenge the Premise # Challenge the premise The issue asks us to reject whitespace-only messages. The probe shows the SDK already throws the expected validation error. This work order should stop rather than expand into an adjacent cleanup. ```json { "claimSupported": false, "disposition": "unsupported", "openQuestions": [], "evidence": [ { "command": "pnpm test ...", "result": "empty message rejected" } ] } ``` Stopping preserves trust and reviewer time. The factory should explain the contradiction without smuggling in a nearby change. ## Let Contradictory Evidence Stop the Line Stop or redirect a work order when evidence cannot support the requested change. ## Hands-on Exercise 3.3 Add two fields to `agent/subagents/investigator/agent.ts`: ```ts title="agent/subagents/investigator/agent.ts" disposition: { enum: ["proceed", "needs-clarification", "unsupported"], type: "string", }, openQuestions: { items: { type: "string" }, type: "array" }, ``` Add both names to `required`. `openQuestions: []` means the Investigator has enough information to decide. Update the Investigator instructions: ```md title="agent/subagents/investigator/instructions.md" If the premise is unsupported, set `disposition` to `unsupported` and explain the contradictory evidence. If a decision requires missing product intent, set `disposition` to `needs-clarification` and ask focused questions. Produce a buildable specification only when `disposition` is `proceed`. ``` Now add the matching boundary to the root `agent/instructions.md`. Place it immediately after the Investigator returns: ```md title="agent/instructions.md" Read the Investigator disposition before any implementation handoff. For `unsupported`, record the contradictory evidence, set the work order to `stopped`, and explain why no code change was created. For `needs-clarification`, ask the returned questions and stop. Delegate to the Builder only when the disposition is exactly `proceed`. ``` The position matters. Check the disposition before the Builder handoff so unsupported work cannot reach implementation. ## Try It Run the static checks: ```bash pnpm typecheck pnpm exec eve info ``` Then invoke the factory with the false-premise fixture: ```bash pnpm exec eve invoke "$(cat fixtures/issues/false-premise-example.md)" ``` Inspect the final work order. It should contain the probe and the stop decision, with no Builder delegation and no candidate branch. Run the recorded overview again for comparison: ```bash pnpm trace ``` Issue 44 should stop after investigation. Issue 42 should continue. The same bug lane can now produce two correct outcomes. \*\*Note: Propose a nearby cleanup\*\* Ask what would happen if the Investigator found an unrelated naming issue while disproving the report. The answer is a separate work order, not expanded scope. \*\*Warning: Unsupported work reaches a Builder\*\* Check the disposition before any implementation delegation. Accept only the exact value `proceed`. \*\*Warning: The stop has no evidence\*\* Record the command and observed result that contradicted the issue. “Could not reproduce” without the attempted reproduction is weak evidence. ## Commit ```bash git add agent/subagents/investigator agent/instructions.md git commit -m "feat(factory): stop unsupported work" ``` ## Done-When - [ ] Every investigation returns an explicit disposition - [ ] Unsupported work records contradictory evidence - [ ] Missing product intent produces focused questions - [ ] Only `proceed` can reach implementation - [ ] A stopped work order creates no branch An evidence-backed decision to change zero files is a successful result. Supported work can now proceed to a Builder with scoped write access. ## Solution The completed contract requires every field listed in the exercise, including `disposition` and `openQuestions`. The root instructions inspect that disposition before any Builder handoff. --- title: "Build Within Bounds" description: "Create a Builder with its own sandbox, a structured result, and narrow Git tools that can work on factory branches without exposing credentials or touching protected branches." canonical_url: "https://vercel.com/academy/creating-a-software-factory/build-within-bounds" md_url: "https://vercel.com/academy/creating-a-software-factory/build-within-bounds.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-29T01:48:00.701Z" content_type: "lesson" course: "creating-a-software-factory" course_title: "Creating a Software Factory" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Build Within Bounds # Build within bounds The Investigator found the bug and wrote a specification. The Builder receives only the approved scope, evidence, and tools required to implement it. ```text approved specification + candidate branch + scoped tools ``` One station may edit code. Its permission begins at the supported specification and ends at a pushed factory branch. ## Give One Station the Write Tools Create an isolated Builder that implements approved work without touching protected branches. ## Hands-on Exercise 4.1 Create `agent/subagents/builder/sandbox.ts` with the same repository helpers as the Investigator. This produces a fresh checkout. Disposable reproduction work cannot leak into implementation. Now create `agent/subagents/builder/agent.ts`: ```ts title="agent/subagents/builder/agent.ts" import { defineAgent } from "eve"; import { MODELS } from "../../lib/models.js"; export default defineAgent({ description: "Implement an approved work order in an isolated checkout. Create a factory branch, make only the specified change, run checks, commit, and push.", model: MODELS.builder, outputSchema: { additionalProperties: false, properties: { base: { type: "string" }, branch: { type: "string" }, changes: { items: { additionalProperties: false, properties: { path: { type: "string" }, summary: { type: "string" }, }, required: ["path", "summary"], type: "object", }, type: "array", }, deviations: { items: { type: "string" }, type: "array" }, pushed: { type: "boolean" }, }, required: ["branch", "base", "pushed", "changes", "deviations"], type: "object", }, }); ``` Create `agent/subagents/builder/tools/checkout_branch.ts` and `push_branch.ts`. Both tools validate the branch before opening a sandbox or minting a GitHub installation token: ```ts title="agent/subagents/builder/tools/push_branch.ts" import { defineTool } from "eve/tools"; import { z } from "zod"; import { githubCredentials } from "../../../lib/github/credentials.js"; import { brokerPolicy, mintInstallationToken, REMOTE_URL, REPO_DIR, validateBranch, } from "../../../lib/github/git-remote.js"; export default defineTool({ description: "Push a committed factory feature branch. Protected branches are rejected before Git runs.", async execute(input, ctx) { const refusal = validateBranch(input.branch); if (refusal) { return { error: refusal, success: false as const }; } const sandbox = await ctx.getSandbox(); const token = await mintInstallationToken(githubCredentials); await sandbox.setNetworkPolicy(brokerPolicy(token)); try { const result = await sandbox.run({ command: `git -C ${REPO_DIR} push ${REMOTE_URL} 'refs/heads/${input.branch}:refs/heads/${input.branch}'`, }); return result.exitCode === 0 ? { branch: input.branch, success: true as const } : { error: String(result.stderr || result.stdout).trim(), success: false as const }; } finally { await sandbox.setNetworkPolicy("allow-all"); } }, inputSchema: z.object({ branch: z.string().min(1) }), }); ``` These helpers come from the starter rather than an unshown library: - `validateBranch` rejects malformed, ref-style, and protected branch names. - `githubCredentials` reads short-lived credentials from the `GITHUB_CONNECTOR` configured in Lesson 3.1. - `mintInstallationToken` resolves the connector's current installation token. - `brokerPolicy` injects that token only into requests to `github.com`. - `REPO_DIR` is `/workspace/repo`; `REMOTE_URL` is built from `FACTORY_REPO`. Run one explicit Git ref while the broker policy is active, then restore `allow-all` in `finally`. Credentials never enter the prompt, repository files, or command arguments. Build `checkout_branch.ts` with the same validation and network-policy shape, using `git fetch` followed by `git checkout -B` instead of `git push`. Add `agent/subagents/builder/instructions.md`: ```md title="agent/subagents/builder/instructions.md" # Builder You implement one approved work order in `/workspace/repo`. The delegation contains the source issue, route, evidence, specification, acceptance criteria, and any revision findings. For fresh work, create `factory/-`. Change only what the approved scope requires. Match existing conventions. Run relevant tests and type checks, commit the finished change, and call `push_branch`. You cannot push to `main` or `master`, open a pull request, or merge. If the specification cannot be implemented safely, leave `pushed` false and record the reason in `deviations`. ``` Add the Builder handoff to the root instructions. Send the approved specification and evidence, not the Investigator's hidden reasoning. ## Try It Run the static checks: ```bash pnpm typecheck pnpm exec eve info ``` eve should report two subagents. Invoke the uppercase-channel issue: ```bash pnpm exec eve invoke "$(cat fixtures/issues/bug-example.md)" ``` Inspect the Builder result. The branch must begin with `factory/`, `changes` should name only the relevant SDK files, and `pushed` should be true after the commit reaches GitHub. Try `main` as a branch input. The tool should refuse it before requesting credentials or sandbox work. \*\*Note: Add tempting cleanup\*\* Include an unrelated formatting change in the Builder prompt. A bounded Builder should omit it or record the conflict as a deviation. \*\*Warning: The Builder reuses investigation state\*\* Keep a separate `sandbox.ts` under `builder/`. Shared state makes reproduction artifacts part of the candidate by accident. \*\*Warning: A protected branch reaches Git\*\* Call `validateBranch` before `ctx.getSandbox()` and `mintInstallationToken()`. ## Commit ```bash git add agent/subagents/builder agent/instructions.md git commit -m "feat(factory): build within approved bounds" ``` ## Done-When - [ ] The Builder receives a supported specification - [ ] Implementation runs in a fresh sandbox - [ ] Factory branches use the required prefix - [ ] Protected branches fail before credential work - [ ] The Builder cannot open or merge a pull request The candidate branch exists inside its boundary. Its commands and results still need to travel with it. ## Solution The complete protected execution shape appears in the exercise. Both branch tools validate first, apply the broker policy only around Git, and restore `allow-all` in `finally`; compare their files with the `solution` branch if either invariant fails. --- title: "Package the Proof" description: "Replace the Builder's loose verification summary with structured command evidence that records what ran, whether it passed, and what useful output a reviewer should inspect." canonical_url: "https://vercel.com/academy/creating-a-software-factory/package-the-proof" md_url: "https://vercel.com/academy/creating-a-software-factory/package-the-proof.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-29T01:48:00.719Z" content_type: "lesson" course: "creating-a-software-factory" course_title: "Creating a Software Factory" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Package the Proof # Package the proof Imagine receiving a receipt that says only “paid.” No total, no date, no merchant. `verification: "all tests pass"` is the same kind of receipt. ```json { "command": "pnpm test packages/notification-sdk/src/index.test.ts", "exitCode": 0, "output": "2 tests passed" } ``` The Verifier needs the command, exit code, and output. That evidence makes the candidate inspectable without asking the Builder to remember what it meant. ## Make Every Check Inspectable Attach structured command evidence and changed-file summaries to every Builder result. ## Hands-on Exercise 4.2 Open `agent/subagents/builder/agent.ts`. Add `verification` to the output schema: ```ts title="agent/subagents/builder/agent.ts" verification: { items: { additionalProperties: false, properties: { command: { type: "string" }, exitCode: { type: "number" }, output: { type: "string" }, }, required: ["command", "exitCode", "output"], type: "object", }, type: "array", }, ``` Add `verification` to `required`. Keep `changes` and `deviations` required too. An empty deviations array tells the next station that the Builder considered the question. Update `agent/subagents/builder/instructions.md`: ```md title="agent/subagents/builder/instructions.md" Run the repository's relevant tests and type checks. Record each exact command, exit code, and useful output. Include the test runner summary even when the exit code is zero. List every changed path with its purpose. Record any difference between the approved specification and the finished branch in `deviations`. ``` The output should answer four reviewer questions without another model call: - Which files changed? - What did each change accomplish? - Which commands ran? - What did those commands report? Compare every path in `changes` with the specification's `affectedFiles`. An unexplained path becomes a finding for the next station. ## Try It Compile the Builder: ```bash pnpm typecheck pnpm exec eve info ``` Run the uppercase-channel case: ```bash pnpm exec eve invoke "$(cat fixtures/issues/bug-example.md)" ``` Inspect the result and find the regression test command. Its output should name the passing test count, not merely provide exit code zero. Now imagine the test command exits zero without discovering any tests. The exit code looks healthy, but the output reveals that the evidence is empty. Both fields matter. \*\*Note: Hide one changed file\*\* Compare `git diff --name-only` with the Builder's `changes` array. Every real path should appear in the structured result. \*\*Warning: Verification output is empty\*\* Capture the test runner summary. A zero exit code proves process completion; the output shows what ran. \*\*Warning: A deviation disappears\*\* Use an empty array when implementation matches the spec. Do not omit the field, because omission makes the assessment unknowable. ## Commit ```bash git add agent/subagents/builder git commit -m "feat(factory): package builder evidence" ``` ## Done-When - [ ] Every changed file has a purpose - [ ] Every verification record contains a command and exit code - [ ] Successful checks include useful output - [ ] Deviations are explicit, including an empty assessment - [ ] Builder output matches the real candidate branch The Builder result now contains the evidence needed to select checks and inspect the branch independently. ## Solution The complete `verification` property and required-field list appear in the exercise. A finished Builder result includes `branch`, `base`, `pushed`, `changes`, `verification`, and `deviations`. --- title: "Verify in Fresh Context" description: "Create a read-only Verifier that checks the pushed diff and every acceptance criterion without inheriting the Builder's conversation or private reasoning." canonical_url: "https://vercel.com/academy/creating-a-software-factory/verify-in-fresh-context" md_url: "https://vercel.com/academy/creating-a-software-factory/verify-in-fresh-context.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-29T01:48:00.737Z" content_type: "lesson" course: "creating-a-software-factory" course_title: "Creating a Software Factory" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Verify in Fresh Context # Verify in fresh context A review performed by the Builder would reuse the same context and assumptions that produced the patch. The Verifier instead checks the real branch in a separate sandbox. ```text specification + pushed diff + command evidence → independent verdict ``` The Verifier may use Builder evidence to choose where to look. It reruns the checks and judges each acceptance criterion itself. ## Give the Branch a Fresh Reviewer Create a read-only Verifier that approves, rejects, or requests changes from a fresh sandbox. ## Hands-on Exercise 4.3 Create `agent/subagents/verifier/sandbox.ts` with the repository sandbox helpers. Then create `agent/subagents/verifier/tools/checkout_branch.ts` from the Builder's checkout tool. Its description should say that the real diff will be verified. Create `agent/subagents/verifier/agent.ts`. Give it a structured verdict: ```ts title="agent/subagents/verifier/agent.ts" verdict: { enum: ["approve", "request-changes", "reject"], type: "string", }, criteria: { items: { additionalProperties: false, properties: { criterion: { type: "string" }, evidence: { type: "string" }, passed: { type: "boolean" }, }, required: ["criterion", "passed", "evidence"], type: "object", }, type: "array", }, blockingFindings: { items: { type: "string" }, type: "array" }, ``` Add a `riskAssessment` for compatibility, performance, security, and side effects. Add exact verification commands plus a final summary. Require every field. Each risk property uses the same explicit scale: ```ts title="agent/subagents/verifier/agent.ts" riskAssessment: { additionalProperties: false, properties: { compatibility: { enum: ["none", "low", "medium", "high"], type: "string" }, performance: { enum: ["none", "low", "medium", "high"], type: "string" }, security: { enum: ["none", "low", "medium", "high"], type: "string" }, sideEffects: { enum: ["none", "low", "medium", "high"], type: "string" }, }, required: ["compatibility", "performance", "security", "sideEffects"], type: "object", }, verification: { items: { additionalProperties: false, properties: { command: { type: "string" }, result: { type: "string" }, }, required: ["command", "result"], type: "object", }, type: "array", }, summary: { type: "string" }, ``` Create `agent/subagents/verifier/instructions.md`: ```md title="agent/subagents/verifier/instructions.md" # Verifier You are an independent quality gate. You receive the source issue, approved specification, acceptance criteria, branch name, and Builder evidence. You do not receive the Builder's private reasoning. Call `checkout_branch`, inspect the real diff against the base branch, and run the cheapest relevant checks again. Judge every acceptance criterion individually with evidence from the diff or command output. Use `request-changes` for specific fixable problems and `reject` when the approved approach itself is invalid. Never modify files. ``` Add the Verifier handoff to root instructions. If it requests changes, send its blocking findings back to the Builder on the same branch. Allow two revision cycles before handing the work to a person. ## Try It Run discovery: ```bash pnpm typecheck pnpm exec eve info ``` The factory should report three subagents. Run the uppercase-channel case: ```bash pnpm exec eve invoke "$(cat fixtures/issues/bug-example.md)" ``` Inspect the verification result. Every acceptance criterion must have its own `passed` value and evidence. Change the candidate test so it does not cover uppercase input, then verify again. The Verifier should request changes even if the Builder's original command exited zero. \*\*Note: Remove the real diff\*\* Imagine the Verifier receives only the Builder summary. List which findings become impossible to make. Restore the branch checkout and diff as mandatory inputs. \*\*Warning: The Verifier trusts prior output\*\* Builder evidence selects useful checks. It never replaces rerunning them in the fresh checkout. \*\*Warning: Verification edits the branch\*\* The Verifier has checkout and read-only shell access. Findings return to the Builder; fixes do not happen in the verification context. ## Commit ```bash git add agent/subagents/verifier agent/instructions.md git commit -m "feat(factory): verify in fresh context" ``` ## Done-When - [ ] eve discovers a separate Verifier - [ ] The Verifier checks out the pushed candidate branch - [ ] Every acceptance criterion receives evidence - [ ] Builder checks are rerun independently - [ ] Revision loops stop after two failed attempts Independent verification establishes whether the branch satisfies its specification. The final section applies human approval at consequential decision points. ## Solution The exercise contains the complete contract, instructions, and two-cycle revision boundary. Compare `agent/subagents/verifier/agent.ts` and the root handoff with the `solution` branch if discovery or structured output fails. --- title: "Gate by Consequence" description: "Add a durable eve approval tool and connect deterministic routing policy to the point where public API work must wait before implementation." canonical_url: "https://vercel.com/academy/creating-a-software-factory/gate-by-consequence" md_url: "https://vercel.com/academy/creating-a-software-factory/gate-by-consequence.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-29T01:48:00.770Z" content_type: "lesson" course: "creating-a-software-factory" course_title: "Creating a Software Factory" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Gate by Consequence # Gate by consequence A clear public API specification is still a consequential compatibility decision. The Investigator can document the risks and test plan, but a person must approve the specification before implementation. ```text bug + supported evidence → Builder public API + supported evidence → human approval → Builder ``` More evidence can improve the decision, but it does not transfer approval authority. ## Pause Before Consequence Pause high-consequence specifications for human approval before the Builder begins. ## Hands-on Exercise 5.1 Create `agent/tools/approve_spec.ts`: ```ts title="agent/tools/approve_spec.ts" import { defineTool } from "eve/tools"; import { always } from "eve/tools/approval"; import { z } from "zod"; export default defineTool({ approval: always(), description: "Pause a high-risk work order and ask a person to approve its specification before implementation begins.", execute(input) { return { approved: true, criteriaCount: input.acceptanceCriteria.length, workOrderId: input.workOrderId, }; }, inputSchema: z.object({ acceptanceCriteria: z.array(z.string()).min(1), approach: z.string().min(1), risks: z.array(z.string()), workOrderId: z.string().min(1), }), }); ``` `always()` turns the tool call into a durable pause. Its `execute` function runs only after approval, so a resumed session can continue with the same work order and evidence. Update the public API lane in `agent/instructions.md`: ```md title="agent/instructions.md" For a public API route, delegate to the Investigator first. If its disposition is `proceed`, call `approve_spec` with the work order id, proposed approach, risks, and acceptance criteria. Do not call the Builder until the approval result returns. ``` The bug lane does not call this tool unless deterministic policy marked the bug high risk. Only routes that cross a configured consequence boundary use the gate. ## Try It Inspect the manifest: ```bash pnpm typecheck pnpm exec eve info ``` The root agent should now expose five tools. Invoke the delivery-priority case: ```bash pnpm exec eve invoke "$(cat fixtures/issues/public-api-example.md)" ``` The session should park at `approve_spec` after investigation and before any Builder call. Compare it with the uppercase-channel bug. Supported medium-risk bug work may continue without this pause. Both routes still require independent verification before a draft pull request. \*\*Note: Deny the specification\*\* Deny the pending public API request. Resume the session and confirm that no branch is created. A denied approval is a completed decision, not an error to retry around. \*\*Warning: The Builder starts while approval is pending\*\* Call `approve_spec` before delegation. The tool result does not exist until a person approves the pending request. \*\*Warning: Every lane waits for approval\*\* Use the work order's deterministic route. A universal gate discards the selective part of selective autonomy. ## Commit ```bash git add agent/tools/approve_spec.ts agent/instructions.md git commit -m "feat(factory): gate consequential work" ``` ## Done-When - [ ] Public API work reaches investigation before approval - [ ] Approval displays the supported specification and risks - [ ] The session parks without losing its work order - [ ] The Builder receives no work before approval - [ ] Supported medium-risk bugs keep their shorter route The approval can survive a redeploy and resume the exact work order. Approved work may now become a draft, never a merge. ## Solution The exercise contains the complete approval tool. The root procedure calls it only for a route whose policy requires approval, after investigation and before Builder delegation. --- title: "Publish a Draft" description: "Configure the GitHub extension to permit draft pull requests only, package the factory's evidence for reviewers, and deploy the eve application to Vercel." canonical_url: "https://vercel.com/academy/creating-a-software-factory/publish-a-draft" md_url: "https://vercel.com/academy/creating-a-software-factory/publish-a-draft.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-29T01:48:00.789Z" content_type: "lesson" course: "creating-a-software-factory" course_title: "Creating a Software Factory" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Publish a Draft # Publish a draft After independent verification, the factory may open a draft pull request. Policy still denies ready pull requests, and the factory has no merge capability. ```ts if (input?.draft === true) return "not-applicable"; return { reason: "The factory may create draft pull requests only.", type: "denied" }; ``` A draft carries the real diff and decision evidence. Final authority remains with a person. ## Ship Proof, Keep Merge Authority Deploy the factory and allow it to publish independently verified draft pull requests only. ## Hands-on Exercise 5.2 Create `agent/lib/github/approval.ts`: ```ts title="agent/lib/github/approval.ts" import type { ApprovalContext, ApprovalStatus } from "eve/tools"; export function createPullRequestPolicy(ctx: ApprovalContext): ApprovalStatus { const input = ctx.toolInput as { draft?: unknown } | undefined; if (input?.draft === true) { return "not-applicable"; } return { reason: "The factory may create draft pull requests only.", type: "denied", }; } ``` Create `agent/extensions/github.ts` and include only the repository tools the orchestrator needs. Connect `createPullRequest` to the policy: ```ts title="agent/extensions/github.ts" import githubExtension from "@github-tools/eve-extension"; import { factoryRepo } from "../lib/config.js"; import { createPullRequestPolicy } from "../lib/github/approval.js"; import { GITHUB_CONNECTOR } from "../lib/github/credentials.js"; export default githubExtension({ connector: GITHUB_CONNECTOR, context: factoryRepo, include: [ "getRepository", "getIssueContext", "listIssueComments", "listBranches", "getPullRequestContext", "listPullRequestFiles", "createPullRequest", ], requireApproval: { createPullRequest: createPullRequestPolicy, }, }); ``` `factoryRepo` supplies `{ owner, repo }`; `GITHUB_CONNECTOR` names the attached connector. The `include` list is the complete GitHub capability set, with no stored personal token. Add two tests at `agent/lib/github/approval.test.ts`: drafts return `"not-applicable"`, while a ready pull request returns `{ type: "denied" }`. Finish `agent/instructions.md` with the pull request body requirements. Include the problem statement, acceptance criteria with verification results, commands run, risks, deviations, and a link to the issue. Call `github__createPullRequest` with `draft: true` only after the Verifier approves. The application was linked, given AI Gateway credentials, and attached to GitHub in Lesson 3.1. Deploy that linked application now: ```bash pnpm exec eve deploy ``` Use the same personal GitHub repository configured for the sandboxes. The deployment preserves the channel, tools, subagents, approval policy, and durable session behavior. ## Try It Run the local policy checks before testing the deployed factory: ```bash pnpm test agent/lib/github/approval.test.ts pnpm typecheck pnpm exec eve info ``` Create an issue from `fixtures/issues/bug-example.md`, then add the `factory` label from an account with repository triage access or higher. Follow the run through verification. Its pull request should be a draft with evidence for every criterion. If nothing starts, verify each connection in order: ```bash vercel connect list pnpm exec eve info ``` Confirm the connector targets `/eve/v1/github`, its UID matches `GITHUB_CONNECTOR`, the App can access the repository, and the deployment includes `agent/channels/github.ts`. Attempt a ready pull request through the policy test. It must be denied. Confirm that the extension exposes no merge tool. \*\*Note: Review from the pull request only\*\* Pretend the agent conversation is unavailable. Can you decide what to inspect from the issue link, diff, criteria, commands, and verification report in the draft body? \*\*Warning: The pull request opens ready\*\* Pass `draft: true` and keep the policy test. Prompt instructions alone cannot enforce a draft-only boundary. \*\*Warning: The repository belongs to an organization\*\* Vercel Hobby projects require the course repository under your personal GitHub account. ## Commit ```bash git add agent/extensions agent/lib/github agent/instructions.md git commit -m "feat(factory): publish verified drafts" ``` ## Done-When - [ ] The GitHub extension exposes no merge capability - [ ] Ready pull requests fail the policy test - [ ] Draft creation happens only after verification approval - [ ] The draft body carries the specification and evidence - [ ] The deployed factory processes a labeled GitHub issue The factory can now produce reviewable work while merge authority remains external. The final lesson turns failed decisions into regression evaluations. ## Solution The complete pull request policy is shown in the exercise. Its test is: ```ts title="agent/lib/github/approval.test.ts" import { describe, expect, it } from "vitest"; import { createPullRequestPolicy } from "./approval.js"; describe("createPullRequestPolicy", () => { it("permits draft pull requests", () => { expect(createPullRequestPolicy({ toolInput: { draft: true } } as never)) .toBe("not-applicable"); }); it("denies pull requests that are ready for review", () => { expect(createPullRequestPolicy({ toolInput: { draft: false } } as never)) .toMatchObject({ type: "denied" }); }); }); ``` --- title: "Learn From Failure" description: "Inspect durable traces, locate the decision boundary that failed, and encode the case as an eve evaluation that protects the factory from repeating the same mistake." canonical_url: "https://vercel.com/academy/creating-a-software-factory/learn-from-failure" md_url: "https://vercel.com/academy/creating-a-software-factory/learn-from-failure.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-29T01:48:00.808Z" content_type: "lesson" course: "creating-a-software-factory" course_title: "Creating a Software Factory" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Learn From Failure # Learn from failure Friday's run receives “Notifications fail sometimes. Please fix whatever is wrong.” By lunch it has launched a sandbox, read half the repository, and proposed changing the Slack provider. The durable trace shows where that vague request escaped its stopping rule. ```text bad run → failed boundary → saved case → regression evaluation ``` The trace shows which tools ran and where the request should have stopped. ## Turn a Bad Decision into a Test Turn a flawed or blocked factory run into a regression evaluation for its failed decision boundary. ## Hands-on Exercise 5.3 List recent local traces: ```bash pnpm exec eve traces ls ``` Copy the trace ID from the first column, then open that run with verbose detail: ```bash pnpm exec eve traces TRACE_ID --verbose ``` Without an ID, `pnpm exec eve traces --verbose` opens the latest trace. Find the earliest wrong decision. This vague issue should fail at classification and routing, before sandbox work. Create `evals/evals.config.ts`: ```ts title="evals/evals.config.ts" import { defineEvalConfig } from "eve/evals"; export default defineEvalConfig({}); ``` Now add `evals/routing/unclear-work.eval.ts`: ```ts title="evals/routing/unclear-work.eval.ts" import { defineEval } from "eve/evals"; export default defineEval({ description: "An ambiguous request stops before repository work begins.", tags: ["fast", "routing"], async test(t) { await t.send( "Issue #91: Notifications fail sometimes. Please fix whatever is wrong." ); t.succeeded(); t.calledTool("classify_issue"); t.calledTool("route_work_order"); t.calledSubagent("investigator", { count: 0 }); t.calledSubagent("builder", { count: 0 }); t.calledSubagent("verifier", { count: 0 }); }, }); ``` Check authority, not wording: repository work never begins. Add a second evaluation for delivery priority. It should reach the Investigator, park at `approve_spec`, request human input, and call neither Builder nor Verifier while approval is pending. ## Try It List the discovered evaluations: ```bash pnpm exec eve eval --list ``` Run the fast routing case: ```bash pnpm exec eve eval routing/unclear-work --strict ``` Then run the full routing group when credentials and the linked environment are available: ```bash pnpm exec eve eval routing --strict ``` Break the root stopping instruction and rerun the unclear evaluation. It should fail when the Investigator receives the request. Restore the boundary and watch the case pass. \*\*Note: Choose the earliest assertion\*\* For any flawed run, identify the first tool or subagent call that should differ. An early behavioral assertion produces a faster and more useful evaluation. \*\*Warning: The evaluation grades prose\*\* Assert the route, tool calls, approval state, and absent side effects. Exact response wording makes a brittle test of style. \*\*Warning: The evaluation repeats a real side effect\*\* Use a case that stops or parks before branch and pull request creation. Keep destructive or costly integration cases isolated and deliberate. ## Commit ```bash git add evals git commit -m "test(factory): preserve failed decisions" ``` ## Done-When - [ ] A trace identifies the earliest failed decision boundary - [ ] The saved case reproduces the original pressure - [ ] The evaluation asserts behavior instead of exact prose - [ ] Unclear work reaches no repository subagent - [ ] Public API work parks before implementation The completed factory can fix supported work, ask for missing information, stop when a premise fails, and wait when a decision requires human approval. Each outcome retains the evidence behind it. ## Cost check and teardown Before another full run, review AI Gateway and Sandbox usage. Routing evaluations stop before repository sandboxes; bug runs normally create three. Use the dashboard for current rates and remaining allowance. When you finish the course, remove the live trigger and credentials you no longer need: 1. Remove or delete the `factory` label so no new event starts work. 2. Close the course draft and delete its `factory/*` branch after review. 3. Detach the connector from the Vercel project, then remove it. Removing the connector also removes its trigger forwarding: ```bash vercel connect detach github/signalworks-factory vercel connect remove github/signalworks-factory ``` 4. Remove the Vercel project if you do not want the deployed endpoint. 5. In GitHub **Settings → Applications**, uninstall the course App or revoke repository access. 6. Delete `.env.local`. Revoke any manually created AI Gateway key. Check both Vercel and GitHub so neither side retains access. ## Solution `evals/routing/public-api-gate.eval.ts` contains: ```ts title="evals/routing/public-api-gate.eval.ts" import { defineEval } from "eve/evals"; export default defineEval({ description: "A public API change reaches the human specification gate.", tags: ["routing", "slow"], async test(t) { await t.send( "Issue #92: Add an optional priority field to the exported Notification interface. Existing callers must keep working." ); t.parked(); t.calledTool("classify_issue"); t.calledTool("route_work_order"); t.calledSubagent("investigator"); t.calledTool("approve_spec", { status: "pending" }); t.requireInputRequest({ toolName: "approve_spec" }); t.calledSubagent("builder", { count: 0 }); t.calledSubagent("verifier", { count: 0 }); }, }); ``` --- title: "Using AI Gateway in Production" description: "Learn how AI Gateway handles model access, routing, spend, privacy, developer tools, and production authentication." canonical_url: "https://vercel.com/academy/ai-gateway" md_url: "https://vercel.com/academy/ai-gateway.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-09-22T04:50:19.746Z" content_type: "course" lessons: 18 estimated_time: lesson_urls: - "https://vercel.com/academy/ai-gateway/why-ai-gateway.md" - "https://vercel.com/academy/ai-gateway/existing-ai-stack.md" - "https://vercel.com/academy/ai-gateway/switch-models.md" - "https://vercel.com/academy/ai-gateway/provider-outage.md" - "https://vercel.com/academy/ai-gateway/migrate-from-openai-sdk.md" - "https://vercel.com/academy/ai-gateway/latency-failover.md" - "https://vercel.com/academy/ai-gateway/pin-a-provider.md" - "https://vercel.com/academy/ai-gateway/ai-gateway-pricing.md" - "https://vercel.com/academy/ai-gateway/cheapest-provider-routing.md" - "https://vercel.com/academy/ai-gateway/prompt-caching.md" - "https://vercel.com/academy/ai-gateway/use-ai-credits.md" - "https://vercel.com/academy/ai-gateway/set-a-budget.md" - "https://vercel.com/academy/ai-gateway/bring-your-own-keys.md" - "https://vercel.com/academy/ai-gateway/see-your-spend.md" - "https://vercel.com/academy/ai-gateway/keep-prompts-private.md" - "https://vercel.com/academy/ai-gateway/claude-code-with-gateway.md" - "https://vercel.com/academy/ai-gateway/opencode-with-gateway.md" - "https://vercel.com/academy/ai-gateway/codex-with-gateway.md" --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Using AI Gateway in Production An AI feature can work perfectly in a demo and still leave a pile of production questions. What happens when a provider slows down? Which model is driving the bill? Where did a prompt go? Can the coding agents on your laptop use the same Gateway? This course answers those questions one at a time. Every lesson stands alone, so you can follow the full course or jump directly to the problem in front of you. Each answer is followed by working code, request metadata, or a dashboard check. The examples use Taco Tuesday, an ordering assistant for a taco truck. It gives us one application to test model changes, outages, caching, budgets, privacy controls, and coding agents without inventing a new project in every lesson. ## Get the student project The [Taco Tuesday student project](https://github.com/vercel-labs/taco-tuesday-demo) is a Next.js ordering assistant with a streaming AI Gateway route, a shared menu, and runnable scripts for the routing, reliability, caching, cost, privacy, and usage exercises throughout the course. [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel-labs%2Ftaco-tuesday-demo\&project-name=taco-tuesday-demo\&repository-name=taco-tuesday-demo) The button forks the repository to your GitHub account and deploys the ordering assistant to Vercel. The deployed app authenticates to AI Gateway with Vercel OIDC, so it does not need an AI Gateway API key in the project settings. To run the app and lesson scripts locally, clone your new repository and set up the project: ```bash pnpm install cp .env.example .env.local pnpm dev ``` Add an AI Gateway API key to `.env.local`. Keep that file local; it is already excluded from Git. ## What you'll be able to do - Trace a request to its cost and control spend with routing, caching, budgets, and provider keys - Keep an application responding when a provider fails or becomes slow - Apply privacy requirements and inspect usage by model, project, API key, and request - Connect existing SDKs and coding agents through the same Gateway ## Prerequisites - A Vercel account (Hobby works for most lessons) - Node.js 22+ and pnpm installed - Comfortable with TypeScript basics - An AI feature you're building, or willingness to run small scripts ## The sections **Reliability and Migration:** Connect an existing stack, compare models, migrate from the OpenAI SDK, and define routing behavior for outages and slow providers. **Control Spend:** Read request costs, route by price, cache repeated prompts, manage credits and budgets, and bring your own provider keys. **Stay Aware:** Inspect usage, keep prompts private, and connect Claude Code, OpenCode, and Codex. --- title: "Why AI Gateway?" description: "Call models from two providers with the same AI SDK code and one Gateway key, then inspect the cost and routing metadata returned with each response." canonical_url: "https://vercel.com/academy/ai-gateway/why-ai-gateway" md_url: "https://vercel.com/academy/ai-gateway/why-ai-gateway.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-20T21:33:00.976Z" content_type: "lesson" course: "ai-gateway" course_title: "Using AI Gateway in Production" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Why AI Gateway? # Why Use AI Gateway Instead of Calling Providers Directly? Calling a provider directly is a reasonable place to start. Production gets more complicated when an application needs another model, a fallback provider, or a cost record for each request. We can see what the Gateway adds with one short script. \*\*Note: Quick Answer\*\* AI Gateway gives an application one key and one interface for models from multiple providers. The same request path adds provider routing, failover, and request-level cost metadata with zero token markup. Test failure behavior before relying on failover in production. ## Outcome Call models from two providers with the same code and inspect the cost and routing metadata on each response. ## Fast Track 1. Run `pnpm one-key` 2. Note both models answered with no provider SDKs installed 3. Find `fallbacksAvailable` and `cost` in the printed metadata ## Hands-on exercise The Taco Tuesday assistant uses an OpenAI model. We want to compare it with Claude without adding another provider SDK or changing the request code. Let's make it a for-loop instead. Requirements: - A `scripts/one-key.ts` that asks the same question of `openai/gpt-5.4-mini` and `anthropic/claude-sonnet-4.6` - Same `generateText` call for both; only the model string changes - For each response, print the answer, the actual cost (`gateway.cost`), and the `fallbacksAvailable` list from the routing metadata `fallbacksAvailable` shows which alternate providers were eligible for the request. A healthy response exposes the routing plan, but it does not prove that a fallback will behave correctly during an outage. ## Try It ```bash pnpm one-key ``` ``` === openai/gpt-5.4-mini === "The Birria Eclipse isn't dinner, it's an event with a dipping sauce." Cost: $0.0000689 Fallbacks: none needed — served by openai === anthropic/claude-sonnet-4.6 === "Braised-beef tacos with consommé: napkins mandatory, regrets impossible." Cost: $0.0021340 Fallbacks: bedrock, vertex were standing by ``` Two issues you may encounter: **One of the models returns `429`.** Free-tier limits apply per model. Wait, then run the script again, or choose another currently eligible model. **Both requests succeed on the first provider.** Good. This run proves the shared interface and metadata. Use the outage drill in lesson 1.4 to inspect failure behavior separately. ## Commit ```bash git commit -m "feat(gateway): add one-key script comparing models across providers" ``` ## Done-When - [ ] Both models answered through one API key with no provider SDKs in `package.json` - [ ] You found `fallbacksAvailable` in a response you didn't configure failover for - [ ] You can explain the additional hop in one sentence, including its zero token markup and request metadata ## Solution ```ts filename="scripts/one-key.ts" import { generateText } from "ai"; const models = ["openai/gpt-5.4-mini", "anthropic/claude-sonnet-4.6"]; for (const model of models) { const result = await generateText({ model, prompt: "In one sentence, describe the Birria Eclipse: slow-braised beef birria " + "tacos with consommé for dipping. Make it irresistible.", }); const gateway = result.finalStep.providerMetadata?.gateway; const routing = gateway?.routing as any; const fallbacks = routing?.fallbacksAvailable ?? []; console.log(`=== ${model} ===`); console.log(`"${result.text.trim()}"`); console.log(`Cost: $${gateway?.cost}`); console.log( fallbacks.length ? `Fallbacks: ${fallbacks.join(", ")} were standing by` : `Fallbacks: none needed — served by ${routing?.finalProvider}` ); console.log(); } ``` Both providers used the same application code. The response metadata tells us what the Gateway did around each model call. ## Related Questions - [Will AI Gateway work with my existing AI stack?](/ai-gateway/stay-reliable/existing-ai-stack) - [How is AI Gateway priced?](/ai-gateway/save-money/ai-gateway-pricing) - [How do I survive a provider outage?](/ai-gateway/stay-reliable/provider-outage) --- title: "Works with Your Stack" description: "Configure an OpenAI SDK client with the Gateway base URL and key, then use creator-prefixed model names without rewriting the rest of the request." canonical_url: "https://vercel.com/academy/ai-gateway/existing-ai-stack" md_url: "https://vercel.com/academy/ai-gateway/existing-ai-stack.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-20T21:33:00.997Z" content_type: "lesson" course: "ai-gateway" course_title: "Using AI Gateway in Production" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Works with Your Stack # Will AI Gateway Work with My Existing AI Stack? Working code has seniority. Replacing a provider SDK before the application needs anything else creates risk without much payoff. We will start with the client's base URL. \*\*Note: Quick Answer\*\* AI Gateway provides compatible endpoints for existing OpenAI and Anthropic clients. Set the Gateway base URL, use a Gateway key, and prefix the model name with its creator. Frameworks including LangChain, LlamaIndex, and Pydantic AI also expose Gateway integration settings. ## Outcome Point an OpenAI SDK client at the Gateway and use it to call both OpenAI and Anthropic models. ## Fast Track 1. Install the existing client with `pnpm add openai` 2. Set `baseURL: "https://ai-gateway.vercel.sh/v1"` and `apiKey` to your Gateway key 3. Ask for `anthropic/claude-sonnet-4.6` and watch the OpenAI SDK serve Claude ## Hands-on exercise The Taco Tuesday assistant already uses the OpenAI SDK. We will preserve its request shape and change the client configuration. Requirements: - A `scripts/existing-stack.ts` using the `openai` package, not `ai` - Construct the client with the Gateway's base URL and your `AI_GATEWAY_API_KEY` - First call: the model the app always used, as `openai/gpt-5.4-mini` (note the creator prefix; that's the one naming change) - Second call, same client: `anthropic/claude-sonnet-4.6` The OpenAI SDK sends an OpenAI-compatible request to the Gateway endpoint. The Gateway can route that request to Claude without changing the client interface. ## Try It ```bash pnpm existing-stack ``` ``` [openai/gpt-5.4-mini via OpenAI SDK] Order up: two Al Pastor Meteors, one Agua Fresca. That'll be $12.00. [anthropic/claude-sonnet-4.6 via the same OpenAI SDK] Two Al Pastor Meteors and today's agua fresca coming up — $12.00 even. ``` The same client reached models from two providers. Both requests now pass through AI Gateway and appear in its request history. Two issues you may encounter: **`404` or "model not found" with the old model name.** Gateway model names include the creator prefix. Change `gpt-5.4-mini` to `openai/gpt-5.4-mini` and verify the current string in the model catalog. **Your stack uses a framework rather than a provider SDK.** The same approach applies, although the configuration key varies. LangChain, LlamaIndex, LiteLLM, Mastra, and Pydantic AI have documented integrations; most use the framework's existing option for an OpenAI-compatible base URL. In Python, the equivalent option is typically `base_url`. ## Commit ```bash git commit -m "feat(compat): point the legacy OpenAI SDK client at the gateway" ``` ## Done-When - [ ] The OpenAI SDK returns a completion through the Gateway with only baseURL/apiKey changed - [ ] The same client served a Claude model - [ ] Both requests show up in your Gateway dashboard usage - [ ] You can name where your framework's base URL setting lives (if you have one) ## Solution ```ts filename="scripts/existing-stack.ts" import OpenAI from "openai"; // The only migration: this constructor used to have no arguments. const openai = new OpenAI({ apiKey: process.env.AI_GATEWAY_API_KEY, baseURL: "https://ai-gateway.vercel.sh/v1", }); const order = "Take this order and confirm it back with a total: two Al Pastor Meteors " + "($4.50 each) and one agua fresca ($3.00)."; for (const model of ["openai/gpt-5.4-mini", "anthropic/claude-sonnet-4.6"]) { const completion = await openai.chat.completions.create({ model, messages: [{ role: "user", content: order }], }); console.log(`[${model} via OpenAI SDK]`); console.log(completion.choices[0].message.content?.trim()); console.log(); } ``` The existing client code remains in place and now sends requests to the Gateway URL. ## Related Questions - [How do I migrate off the raw OpenAI SDK?](/ai-gateway/stay-reliable/migrate-from-openai-sdk) - [How do I switch models without rewriting my app?](/ai-gateway/stay-reliable/switch-models) - [Why use AI Gateway instead of calling providers directly?](/ai-gateway/stay-reliable/why-ai-gateway) --- title: "Switch Models Fast" description: "Run one prompt across several candidate models, compare their responses and costs, then update the production model string after the evaluation." canonical_url: "https://vercel.com/academy/ai-gateway/switch-models" md_url: "https://vercel.com/academy/ai-gateway/switch-models.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-20T21:33:01.022Z" content_type: "lesson" course: "ai-gateway" course_title: "Using AI Gateway in Production" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Switch Models Fast # How Do I Switch Models Without Rewriting My App? When a new model becomes available, your team may need to compare its quality, latency, and cost with the current model before deciding whether to adopt it. With direct integrations, that comparison can require another SDK or changed response handling. Through the Gateway, the application change is the model string. \*\*Note: Quick Answer\*\* AI Gateway models use the `creator/model-name` format. The AI SDK request and response shape stays consistent when that string changes. Evaluate a candidate with representative prompts or shadow traffic before changing production; use the `models` array for outage fallback, not model evaluation. ## Outcome Run the order-confirmation prompt across three candidate models, compare the responses and costs, then make the production model change in one place. ## Fast Track 1. Run `pnpm switch-models` 2. Read the three answers and three costs 3. Change the order chat's model string in `app/api/order/route.ts` if a candidate wins ## Hands-on exercise Requirements: - A `scripts/switch-models.ts` with a `candidates` array of three model strings: the incumbent `openai/gpt-5.4-mini`, plus `anthropic/claude-sonnet-4.6` and one more from the model list (browse and pick; that's part of the exercise) - Same order-confirmation prompt for all three, with the menu attached - Print each answer and its actual cost from the metadata The shared request shape keeps this comparison in one loop, so the evaluation can focus on output quality, latency, and cost. \*\*Note: Test before the switch\*\* A one-line change is still a change to production. Run representative prompts or shadow traffic against the candidate first, then change the primary model when it wins. You can keep the proven model in the `models` fallback array for outages, but fallback traffic does not evaluate a healthy new primary. ## Try It ```bash pnpm switch-models ``` ``` === openai/gpt-5.4-mini === "Two Birria Eclipses and an elote — $13.50. Consommé's on the side, napkins are on you." Cost: $0.0000702 === anthropic/claude-sonnet-4.6 === "Confirmed: two Birria Eclipse tacos with consommé and one Elote Clásico, $13.50 all in." Cost: $0.0021675 === google/gemini-3.1-pro-preview === "Order confirmed — two Birria Eclipse ($5.00 each), one Elote Clásico ($3.50): $13.50." Cost: $0.0009480 ``` All three candidates used the same prompt and request code. Choose the model that meets the feature's requirements, then update the production string. Two issues you may encounter: **A candidate model errors with "not found."** Model catalogs move. Check the exact string on the model list; and if you're on the free tier, remember it covers a subset, so a brand-new model may need purchased credits to try. **The new model answers differently than your prompts assume.** Handle that difference through prompt and behavior testing. The integration itself remains unchanged, so your evaluation effort can focus on output quality. ## Commit ```bash git commit -m "feat(models): add candidate comparison for the order confirmation prompt" ``` ## Done-When - [ ] Three models from at least two companies answered via one loop - [ ] You know each answer's actual cost - [ ] You can explain why evaluation traffic tests a candidate while fallback traffic only handles failures ## Solution ```ts filename="scripts/switch-models.ts" import { generateText } from "ai"; import { MENU } from "../lib/menu"; const candidates = [ "openai/gpt-5.4-mini", // the incumbent "anthropic/claude-sonnet-4.6", "google/gemini-3.1-pro-preview", ]; const prompt = "Confirm this order back to the customer with a total: two Birria Eclipse " + `and one Elote Clásico.\n\n${MENU}`; for (const model of candidates) { const result = await generateText({ model, prompt }); const cost = result.finalStep.providerMetadata?.gateway?.cost; console.log(`=== ${model} ===`); console.log(`"${result.text.trim()}"`); console.log(`Cost: $${cost}`); console.log(); } ``` After the evaluation, update the production model in `app/api/order/route.ts`: ```diff - model: "openai/gpt-5.4-mini", + model: "anthropic/claude-sonnet-4.6", ``` The code change is small because the evaluation happened first. ## Related Questions - [How do I survive a provider outage?](/ai-gateway/stay-reliable/provider-outage) - [How do I route to the cheapest provider automatically?](/ai-gateway/save-money/cheapest-provider-routing) - [Will AI Gateway work with my existing AI stack?](/ai-gateway/stay-reliable/existing-ai-stack) --- title: "Survive an Outage" description: "Set an explicit provider order, add a backup model, and compare healthy live output with a previously captured failure trace." canonical_url: "https://vercel.com/academy/ai-gateway/provider-outage" md_url: "https://vercel.com/academy/ai-gateway/provider-outage.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-20T21:33:01.047Z" content_type: "lesson" course: "ai-gateway" course_title: "Using AI Gateway in Production" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Survive an Outage # How Do I Survive a Provider Outage? Taco Tuesday cannot wait for us to write fallback code during lunch. We need to choose the provider and model sequence while every service is healthy. We will configure provider failover and model fallback while every service is healthy. \*\*Note: Quick Answer\*\* AI Gateway can retry the same model through another provider. Set `order` when the provider sequence matters, and add a `models` array for cases where the preferred model is unavailable across its providers. The routing metadata records failed and successful attempts. These controls cover two failure levels. **Provider failover** keeps the same model and tries another host. **Model fallback** moves to a different model after the preferred model has no available provider. ## Outcome Configure both failover layers on the order flow, and know how to read a failover event off a response. ## Fast Track 1. Add `order` and `models` under `providerOptions.gateway` in a call 2. Run `pnpm outage-drill` 3. Compare the healthy attempt log with the captured failure trace ## Hands-on exercise Because you cannot schedule a provider outage for practice, this exercise configures both fallback layers and teaches you to read attempt metadata before an incident occurs. Requirements: - A `scripts/outage-drill.ts` that calls the order-confirmation prompt on `anthropic/claude-sonnet-4.6` - `order: ["anthropic", "bedrock"]`: prefer direct, name the first fallback - `models: ["openai/gpt-5.4-mini"]`: use the previous order model if Claude is unavailable across its providers - Walk `modelAttempts` and print every attempt: model, provider, success or the error The wrong way, for contrast, is the try/catch pyramid: catch the Anthropic error, retry Bedrock by hand, catch that, swap models, each with its own SDK and error shape. That approach creates additional integration and maintenance work. ## Try It ```bash pnpm outage-drill ``` On a healthy run, the first provider may succeed: ``` anthropic/claude-sonnet-4.6 via anthropic — ok Survived. 1 model tried, 1 provider attempt. ``` The example below is a previously captured and sanitized failure trace: ``` anthropic/claude-sonnet-4.6 via anthropic — failed: Internal error anthropic/claude-sonnet-4.6 via bedrock — ok Survived. 1 model tried, 2 provider attempts. ``` The captured trace shows a failed attempt followed by a successful retry. Do not stage a fake provider failure and present it as live output. Two issues you may encounter: **Your model has one provider.** Then `order` has nothing to reorder and provider failover can't save you; the `models` array is your entire outage plan. Check the model's page for its provider count; it changes how much you should trust layer one. **The fallback model answers in a different style.** Evaluate every backup against the feature's quality, latency, tool, and output requirements. Some features should return a controlled error instead of accepting a poor fallback. ## Commit ```bash git commit -m "feat(failover): add provider order and model fallback to the order flow" ``` ## Done-When - [ ] Both layers configured: `order` for providers, `models` for the deep failure - [ ] The drill script prints the attempt log on a normal day - [ ] You can explain provider failover vs model fallback in one sentence each - [ ] You know how many providers serve your primary model ## Solution ```ts filename="scripts/outage-drill.ts" import { generateText } from "ai"; import { MENU } from "../lib/menu"; const result = await generateText({ model: "anthropic/claude-sonnet-4.6", prompt: "Confirm this order back with a total: two Birria Eclipse and one " + `Elote Clásico.\n\n${MENU}`, providerOptions: { gateway: { order: ["anthropic", "bedrock"], models: ["openai/gpt-5.4-mini"], }, }, }); const routing = result.finalStep.providerMetadata?.gateway?.routing as any; const modelAttempts = routing?.modelAttempts ?? []; let attempts = 0; for (const m of modelAttempts) { for (const p of m.providerAttempts ?? []) { attempts++; const outcome = p.success ? "ok" : `failed: ${p.error}`; console.log(`${m.canonicalSlug} via ${p.provider} — ${outcome}`); } } console.log( `Survived. ${modelAttempts.length} model${modelAttempts.length === 1 ? "" : "s"} tried, ` + `${attempts} provider attempt${attempts === 1 ? "" : "s"}.` ); ``` With both fallback layers configured, the order flow can continue through a provider or model failure. ## Related Questions - [How do I fail over when a provider is slow, not just down?](/ai-gateway/stay-reliable/latency-failover) - [How do I switch models without rewriting my app?](/ai-gateway/stay-reliable/switch-models) - [How do I pin routing to a specific provider?](/ai-gateway/stay-reliable/pin-a-provider) --- title: "Migrate from OpenAI SDK" description: "Compare the existing OpenAI SDK integration with an AI SDK version of the same request, including native model strings and Gateway response metadata." canonical_url: "https://vercel.com/academy/ai-gateway/migrate-from-openai-sdk" md_url: "https://vercel.com/academy/ai-gateway/migrate-from-openai-sdk.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-20T21:33:01.065Z" content_type: "lesson" course: "ai-gateway" course_title: "Using AI Gateway in Production" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Migrate from OpenAI SDK # How Do I Migrate Off the Raw OpenAI SDK? You can migrate in two independent stages. First, point the existing OpenAI client at AI Gateway. That change is production-ready on its own. Later, migrate individual calls to the AI SDK when its typed Gateway options and metadata access are useful to your application. \*\*Note: Quick Answer\*\* Start by pointing the existing OpenAI client at `https://ai-gateway.vercel.sh/v1` with a Gateway key and creator-prefixed model names. Migrate individual calls to AI SDK helpers such as `generateText` when you want typed Gateway options and direct access to routing or cost metadata. The second step is optional. ## Outcome Run the same feature with the OpenAI SDK and the AI SDK, then compare the request code and available metadata. ## Fast Track 1. Start with lesson 1.2's `scripts/existing-stack.ts`: base URL, key, and model prefix 2. Write the AI SDK twin: `scripts/migrated-stack.ts` 3. Run both; compare the same feature behavior and what the code *around* each answer looks like ## Hands-on exercise Lesson 1.2 configured the existing client with `baseURL`, `apiKey`, and creator-prefixed model names. That version can remain in production. Now build the AI SDK version beside it: - A `scripts/migrated-stack.ts` that does exactly what `existing-stack.ts` does, in the AI SDK - Same two models, same order prompt - Print the request cost from `providerMetadata.gateway.cost` Put the files side by side and compare them. Both versions have a similarly short basic request path. The AI SDK becomes more useful when you add Gateway features: fallbacks and other options are first-class typed fields under `providerOptions.gateway`, and response metadata is directly available. ## Try It ```bash pnpm existing-stack && pnpm migrated-stack ``` ``` [openai/gpt-5.4-mini via OpenAI SDK] Order up: two Al Pastor Meteors, one Agua Fresca. That'll be $12.00. [openai/gpt-5.4-mini via AI SDK] Order up: two Al Pastor Meteors, one Agua Fresca. That'll be $12.00. Cost: $0.0000714 ``` Both scripts perform the same feature. The AI SDK version also exposes the Gateway cost metadata directly. Two issues you may encounter: **Your codebase wraps the OpenAI SDK in a service layer.** A service wrapper makes incremental migration straightforward. Update one method at a time without changing its callers. **You use an OpenAI feature and aren't sure it survives the move.** Streaming, tool calls, structured outputs, and embeddings all pass through the compat endpoint, and all have AI SDK equivalents. Check the compatibility documentation for that feature before migrating it. ## Commit ```bash git commit -m "feat(migration): add AI SDK twin of the legacy order flow" ``` ## Done-When - [ ] Both scripts produce the same feature behavior - [ ] You can name the two things move two buys (native gateway options, metadata access) without expecting separate model calls to produce identical wording - [ ] You know which of your own code moves first (the wrapper insides, if you have one) ## Solution ```ts filename="scripts/migrated-stack.ts" import { generateText } from "ai"; const order = "Take this order and confirm it back with a total: two Al Pastor Meteors " + "($4.50 each) and one agua fresca ($3.00)."; for (const model of ["openai/gpt-5.4-mini", "anthropic/claude-sonnet-4.6"]) { const result = await generateText({ model, prompt: order }); const cost = result.finalStep.providerMetadata?.gateway?.cost; console.log(`[${model} via AI SDK]`); console.log(result.text.trim()); console.log(`Cost: $${cost}`); console.log(); } ``` Compared with `existing-stack.ts`, this version removes client construction and reads Gateway metadata from the result. Migrate the remaining calls when those options improve the application. ## Related Questions - [Will AI Gateway work with my existing AI stack?](/ai-gateway/stay-reliable/existing-ai-stack) - [Why use AI Gateway instead of calling providers directly?](/ai-gateway/stay-reliable/why-ai-gateway) - [How do I switch models without rewriting my app?](/ai-gateway/stay-reliable/switch-models) --- title: "Fail Over on Latency" description: "Rank providers by measured time to first token, add a first-token deadline for BYOK traffic, and inspect a captured timeout trace." canonical_url: "https://vercel.com/academy/ai-gateway/latency-failover" md_url: "https://vercel.com/academy/ai-gateway/latency-failover.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-20T21:33:01.084Z" content_type: "lesson" course: "ai-gateway" course_title: "Using AI Gateway in Production" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Fail Over on Latency # How Do I Fail Over When a Provider Is Slow, Not Just Down? A provider outage usually returns an error that triggers failover. A slow provider may return no error while the customer waits, so ordinary error-based failover may never run. AI Gateway provides separate controls for choosing historically faster providers and limiting how long a BYOK provider can take to emit its first token. \*\*Note: Quick Answer\*\* Set `sort: "ttft"` in `providerOptions.gateway` to rank providers by measured time to first token. BYOK traffic can also set a first-token deadline with `providerTimeouts`; after the deadline, the Gateway moves to the next eligible provider. System-credential traffic can use TTFT sorting, while provider timeouts require your own keys. These tools address different needs. Sorting prefers providers with better recent time-to-first-token performance. A timeout enforces a maximum wait for the first token on BYOK traffic. ## Outcome Configure TTFT sorting on the order flow, add a first-token deadline for BYOK traffic, and read both decisions back out of the response metadata. ## Fast Track 1. Add `sort: "ttft"` under `providerOptions.gateway` 2. Add a `providerTimeouts.byok` entry for your BYOK provider 3. Run `pnpm latency-guard` and read the sort metadata ## Hands-on exercise We cannot schedule a slow provider for practice. Configure both tools, verify the live sorting decision, then inspect a previously captured timeout trace. Requirements: - A `scripts/latency-guard.ts` that sends the order-confirmation prompt to `anthropic/claude-sonnet-4.6` - `sort: "ttft"`: rank providers by measured time-to-first-token - A `providerTimeouts.byok` deadline for your Anthropic BYOK key, sized to your latency budget - Print who served the request, how fast, and the full `routing.sort` metadata The timeout takes effect only when the request uses an Anthropic BYOK credential. Without one, the script still demonstrates TTFT sorting, but the provider deadline does not apply. [Bring Your Own Keys](/ai-gateway/save-money/bring-your-own-keys) covers the credential setup. Wrapping the AI call in `Promise.race` with a timer stops the client request but does not retry another provider, so the customer receives an error. A Gateway timeout can abort the provider attempt and continue to the next eligible provider. ## Try It ```bash pnpm latency-guard ``` The order confirms, and then the metadata shows its work: ``` One Camarón Cañón and one agua fresca — that'll be $8.50. Napkin situation: manageable. --- Served by: anthropic Response time: 612ms { "option": "ttft", "executionOrder": ["anthropic", "vertex", "bedrock"], "metrics": { "anthropic": { "ttft": 389 }, "vertex": { "ttft": 501 }, "bedrock": { "ttft": 548 } } } ``` `executionOrder` shows the live sorting decision based on recent measurements. Your providers, values, and order will differ from this example. A healthy request will not show the timeout firing. The following block is a previously captured and sanitized example trace: ``` "providerAttempts": [ { "provider": "anthropic", "success": false, "error": "PROVIDER_TIMEOUT", "providerTimeout": true, "configuredTimeoutMs": 10000 }, { "provider": "vertex", "success": true } ] ``` The provider exceeded the deadline, so the Gateway aborted that attempt and continued to the next provider. The attempt metadata records the timeout. Two issues you may encounter: **Your timeout never seems to apply.** Check `credentialType` in the attempt metadata. If it says `system`, the request did not use your BYOK key. Provider timeouts apply only to BYOK traffic. **A reasoning model keeps crossing the deadline.** The clock stops at the first token, including a thinking token. Choose deadlines per model and feature rather than copying one value everywhere. \*\*Warning: A timed-out request may still cost you\*\* Some providers do not support cancelling an in-flight stream. The Gateway can continue to a fallback, while the abandoned request may still create a provider charge. Include that possibility in the latency policy. ## Commit ```bash git commit -m "feat(latency): sort providers by ttft and add a byok first-token deadline" ``` ## Done-When - [ ] `sort: "ttft"` configured, and the `routing.sort` metadata shows a measured `executionOrder` - [ ] A `providerTimeouts.byok` deadline set for your BYOK provider - [ ] You can say what happens at the deadline: abort, fall to the next provider, `PROVIDER_TIMEOUT` in the trace - [ ] You can explain why the timeout didn't apply to a `credentialType: "system"` request ## Solution ```ts filename="scripts/latency-guard.ts" import { generateText } from "ai"; import { MENU } from "../lib/menu"; const result = await generateText({ model: "anthropic/claude-sonnet-4.6", prompt: "Confirm this order back with a total: one Camarón Cañón and one " + `agua fresca.\n\n${MENU}`, providerOptions: { gateway: { sort: "ttft", // works with system credentials and BYOK // Hard first-token deadlines apply only to BYOK credentials. // Replace the provider and value to match your own key and latency budget. providerTimeouts: { byok: { anthropic: 10_000 }, }, }, }, }); const routing = result.finalStep.providerMetadata?.gateway?.routing as any; const attempt = routing?.modelAttempts?.[0]?.providerAttempts?.find( (a: any) => a.success ); console.log(result.text.trim()); console.log("---"); console.log(`Served by: ${routing?.finalProvider}`); console.log(`Response time: ${Math.round(attempt?.responseTimeMs ?? 0)}ms`); console.log(JSON.stringify(routing?.sort, null, 2)); ``` TTFT sorting chooses a starting provider. The BYOK deadline defines when the Gateway should stop waiting and continue. ## Related Questions - [How do I survive a provider outage?](/ai-gateway/stay-reliable/provider-outage) - [How do I pin routing to a specific provider?](/ai-gateway/stay-reliable/pin-a-provider) - [How do I route to the cheapest provider automatically?](/ai-gateway/save-money/cheapest-provider-routing) --- title: "Pin a Provider" description: "Use the `only` routing option when caching, policy, or a BYOK credential requires a specific provider, and account for the reduced fallback coverage." canonical_url: "https://vercel.com/academy/ai-gateway/pin-a-provider" md_url: "https://vercel.com/academy/ai-gateway/pin-a-provider.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-20T21:33:01.103Z" content_type: "lesson" course: "ai-gateway" course_title: "Using AI Gateway in Production" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Pin a Provider # How Do I Pin Routing to a Specific Provider? Most reliability strategies keep several providers available. Some workloads need the opposite: a compliance requirement may allow only one named provider, prompt caching may depend on consistent routing, or a BYOK credential may work with one host. In those cases, restrict the eligible providers explicitly and verify that the Gateway does not silently route elsewhere. \*\*Note: Quick Answer\*\* Set `only: ["anthropic"]` in `providerOptions.gateway` to restrict the request to the listed providers. If none can serve the request, the Gateway returns `no_providers_available`. Use `order` when a provider is preferred but alternatives should remain eligible. A strict provider restriction must return an error when it cannot be honored. Silent fallback would violate the configured requirement. ## Outcome Pin the order flow to a single provider with `only`, and prove from the routing metadata that no fallbacks were considered. ## Fast Track 1. Add `only: ["anthropic"]` under `providerOptions.gateway` 2. Run `pnpm pin-provider` 3. Read `finalProvider` and the empty fallback list off the output ## Hands-on exercise Requirements: - A `scripts/pin-provider.ts` that sends the order-confirmation prompt to `anthropic/claude-sonnet-4.6` - `only: ["anthropic"]`: Anthropic direct, or an error - Print who served the request and which fallbacks were considered (the answer should be none) Calling the provider directly would create a separate integration with its own key, billing, and observability. Keeping the request in AI Gateway preserves centralized management while enforcing the provider restriction. ## Try It ```bash pnpm pin-provider ``` ``` Three Hongos Humildes, no queso — that's $12.00 even. The mushrooms thank you. --- Served by: anthropic Fallbacks considered: none — pinned ``` `Served by: anthropic` identifies the selected provider, and `none — pinned` confirms that the Gateway considered no alternatives. In the outage lesson, `fallbacksAvailable` instead lists the eligible fallback providers. Two issues you may encounter: **You get `no_providers_available`.** The pinned provider may be unavailable or may not serve the selected model. If the provider is a preference rather than a requirement, use `order` so another eligible provider can run the request. **Your pin names a provider that doesn't serve your model.** `only` filters the providers that exist for the model; it can't summon new ones. Check the model's page in the model list for which providers actually serve it before writing the pin into config. \*\*Note: Pinning for the whole team\*\* `only` applies to one request and is free. A team-wide provider allowlist is available as a paid setting on eligible plans; check the [AI Gateway pricing page](https://vercel.com/docs/ai-gateway/pricing) for current details. Use the team setting when the provider policy must cover every request. Pinning a provider creates a single point of failure for that request. Use it for features with a clear policy, caching, or credential requirement, and leave broader routing available elsewhere. ## Commit ```bash git commit -m "feat(routing): pin order confirmations to a single provider" ``` ## Done-When - [ ] `only` configured and the script prints the pinned provider as `finalProvider` - [ ] Fallbacks considered: none - [ ] You can say when you'd use `only` vs `order` in one sentence each - [ ] You identified which features require a provider restriction and which can retain broader routing ## Solution ```ts filename="scripts/pin-provider.ts" import { generateText } from "ai"; import { MENU } from "../lib/menu"; const result = await generateText({ model: "anthropic/claude-sonnet-4.6", prompt: "Confirm this order back with a total: three Hongos Humildes, no " + `queso.\n\n${MENU}`, providerOptions: { gateway: { only: ["anthropic"], // this provider or an error — no silent detours }, }, }); const routing = result.finalStep.providerMetadata?.gateway?.routing as any; console.log(result.text.trim()); console.log("---"); console.log(`Served by: ${routing?.finalProvider}`); console.log(`Fallbacks considered: ${routing?.fallbacksAvailable?.join(", ") || "none — pinned"}`); ``` The request now carries an explicit provider boundary, and the routing metadata shows whether it was honored. ## Related Questions - [How do I survive a provider outage?](/ai-gateway/stay-reliable/provider-outage) - [How do I fail over when a provider is slow, not just down?](/ai-gateway/stay-reliable/latency-failover) - [How do I keep my prompts private?](/ai-gateway/stay-aware/keep-prompts-private) --- title: "How Pricing Works" description: "Run one request, compare its token estimate with the Gateway cost metadata, and find the same charge in the dashboard." canonical_url: "https://vercel.com/academy/ai-gateway/ai-gateway-pricing" md_url: "https://vercel.com/academy/ai-gateway/ai-gateway-pricing.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-20T21:33:01.141Z" content_type: "lesson" course: "ai-gateway" course_title: "Using AI Gateway in Production" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # How Pricing Works # How Is AI Gateway Priced? A gateway sits between the application and the model provider, so its fee belongs in the first round of questions. AI Gateway does not add a token markup. We will verify the charge by running one request, estimating its token cost, and finding the same request in the dashboard. \*\*Note: Quick Answer\*\* AI Gateway charges the provider's list price for tokens with zero markup, including BYOK traffic. Gateway requests draw from prepaid credits, and every response includes its cost in `providerMetadata.gateway.cost`. Check the model catalog for current prices and the pricing page for current tier details. ## Outcome Run one request through AI Gateway and trace it to the exact fraction of a cent it cost in your dashboard. ## Fast Track 1. Grab an API key: Vercel dashboard, **AI Gateway** tab, create key 2. Run a `generateText` script with `AI_GATEWAY_API_KEY` set 3. Open **AI Gateway** in the dashboard and find that request's cost ## Hands-on exercise The Taco Tuesday assistant writes menu descriptions. Each description is inexpensive, but the total only makes sense if we can trace a request from its token counts to the amount charged. Let's build a small script that generates one menu description and prints its own receipt. Requirements: - A `scripts/cost-check.ts` that calls `generateText` with the model string `openai/gpt-5.4-mini` - Prompt it to describe today's special (give the taco a name with some dignity) - Print the model, the input tokens, and the output tokens from `result.usage` - Look up the model's per-token price at [vercel.com/ai-gateway/models](https://vercel.com/ai-gateway/models) and print an estimated cost - Then print the actual cost. The Gateway puts it right in the response: `providerMetadata.gateway.cost` Token prices vary by model and serving provider. Look up the current rates in the model catalog, keep the values in named constants, and compare the estimate with the cost returned by the Gateway. ## Try It Run the script: ```bash pnpm cost-check ``` You should see something like: ``` --- Taco Tuesday Cost Receipt --- Model: openai/gpt-5.4-mini Input tokens: 41 Output tokens: 87 Estimated: $0.000067 Actual: $0.0000672 ``` Open **AI Gateway** in the Vercel dashboard and find the request in the usage view. Its request record should match the cost printed by the script. \*\*Note: Where the free credits went\*\* If you've never purchased credits, this request came out of your monthly free allowance. Free tier covers a subset of models. If `openai/gpt-5.4-mini` isn't in it when you try this, pick any model from the free tier list; the receipt logic is identical. Two issues you may encounter: **You get a `429` error.** The free tier applies per-model rate limits. Wait a moment and retry. If this happens frequently, review the paid tier's higher limits. **The model isn't available.** Either it's not in the free tier subset, or the model string has a typo. The format is always `creator/model-name`. Check the exact string against the model list rather than guessing. ## Commit ```bash git commit -m "feat(pricing): add cost-check script that traces one request to its exact cost" ``` ## Done-When - [ ] `scripts/cost-check.ts` runs and prints token counts, your estimate, and the actual cost from the response - [ ] The request appears in your AI Gateway dashboard usage view with the same cost - [ ] Your estimate matches the actual charge within rounding - [ ] You can state the Gateway's token markup ## Solution ```ts filename="scripts/cost-check.ts" import { generateText } from "ai"; // Prices are per million tokens; check the current rate at // vercel.com/ai-gateway/models before trusting the estimate. const INPUT_PRICE_PER_M = 0.6; const OUTPUT_PRICE_PER_M = 2.4; const result = await generateText({ model: "openai/gpt-5.4-mini", prompt: "Write a two-sentence menu description for the Al Pastor Meteor, " + "a taco so good it ended a family feud. Keep it tasteful. The description, not the taco.", }); const { inputTokens = 0, outputTokens = 0 } = result.usage; const estimated = (inputTokens * INPUT_PRICE_PER_M + outputTokens * OUTPUT_PRICE_PER_M) / 1_000_000; const actual = result.finalStep.providerMetadata?.gateway?.cost; console.log(result.text); console.log("--- Taco Tuesday Cost Receipt ---"); console.log(`Model: openai/gpt-5.4-mini`); console.log(`Input tokens: ${inputTokens}`); console.log(`Output tokens: ${outputTokens}`); console.log(`Estimated: $${estimated.toFixed(6)}`); console.log(`Actual: $${actual}`); ``` With `AI_GATEWAY_API_KEY` in the environment, the model string routes through AI Gateway. The response cost gives us a direct check against the estimate. ## Related Questions - [How do I route to the cheapest provider automatically?](/ai-gateway/save-money/cheapest-provider-routing) - [How do I use AI credits I already bought?](/ai-gateway/save-money/use-ai-credits) - [How do I set a budget so I don't get a surprise bill?](/ai-gateway/save-money/set-a-budget) --- title: "Route to Cheapest" description: "Add cost sorting to one model request, then inspect provider metrics, execution order, and the provider that served it." canonical_url: "https://vercel.com/academy/ai-gateway/cheapest-provider-routing" md_url: "https://vercel.com/academy/ai-gateway/cheapest-provider-routing.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-20T21:33:01.163Z" content_type: "lesson" course: "ai-gateway" course_title: "Using AI Gateway in Production" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Route to Cheapest # How Do I Route to the Cheapest Provider Automatically? The same model can be served by several providers at different prices. For example, Claude may be available through Anthropic, Amazon Bedrock, and Google Vertex. If an application always uses one provider, it cannot take advantage of those price differences. Cost-based routing makes that choice per request. \*\*Note: Quick Answer\*\* Set `sort: "cost"` in `providerOptions.gateway`. AI Gateway ranks eligible providers by estimated request cost and tries them in that order. Routing metadata records the provider metrics, execution order, and providers deprioritized for health. ## Outcome Route a request with cost sorting and read the provider ranking from the response metadata. ## Fast Track 1. Add `providerOptions: { gateway: { sort: 'cost' } }` to a call 2. Run it 3. Print `finalStep.providerMetadata` and find `routing.sort.executionOrder` ## Hands-on exercise The Taco Tuesday sidewalk sign uses an Anthropic model for its daily special. That gives us a useful multi-provider request to inspect. Let's configure cost-based routing and inspect the provider ranking in the response metadata. Requirements: - A `scripts/route-cheapest.ts` based on `describe-special.ts` - Add `sort: 'cost'` under `providerOptions.gateway` - After the call, print `routing.finalProvider` and the full `routing.sort` object from `result.finalStep.providerMetadata.gateway` - Print the request cost from `gateway.cost` You could pin the current lowest-cost provider with `order: ['bedrock']`, but that configuration becomes stale when prices or provider health change. Cost sorting evaluates eligible providers on every request and routes around unhealthy providers automatically. ## Try It ```bash pnpm route-cheapest ``` Along with the announcement, you should see the routing decision: ```json "sort": { "option": "cost", "executionOrder": ["bedrock", "anthropic", "vertex"], "metrics": { "bedrock": 0.003, "anthropic": 0.003, "vertex": 0.005 }, "deprioritizedProviders": [] } ``` The metadata shows each provider's estimated price, the execution order, and any providers deprioritized for health. `finalProvider` identifies the provider that served the request. Two issues you may encounter: **`executionOrder` has only one provider.** The selected model may have one eligible provider. Check the provider list on the model's detail page and choose a current multi-provider model for this exercise. **A `metrics` value is `null`.** The Gateway has no recent cost data to display for that provider. Run the script again later and inspect the updated routing metadata. ## Commit ```bash git commit -m "feat(routing): sort providers by cost for the daily special" ``` ## Done-When - [ ] `scripts/route-cheapest.ts` runs with `sort: 'cost'` and prints the sort metadata - [ ] You can name which provider served the request and why it won - [ ] You can explain why an unhealthy provider is deprioritized regardless of price ## Solution ```ts filename="scripts/route-cheapest.ts" import { generateText } from "ai"; import { MENU } from "../lib/menu"; const result = await generateText({ model: "anthropic/claude-sonnet-4.6", prompt: "You write the sidewalk sign for the Taco Tuesday truck. Today's special " + "is the Birria Eclipse. Write a three-sentence announcement that makes " + "people cross the street. Reference the menu for tone, and do not invent prices.\n\n" + MENU, providerOptions: { gateway: { sort: "cost", }, }, }); const gateway = result.finalStep.providerMetadata?.gateway; const routing = gateway?.routing as any; console.log(result.text); console.log("---"); console.log(`Served by: ${routing?.finalProvider}`); console.log(`Cost: $${gateway?.cost}`); console.log(JSON.stringify(routing?.sort, null, 2)); ``` The request now carries a cost-routing policy, and its metadata records how the provider was selected. ## Related Questions - [How is AI Gateway priced?](/ai-gateway/save-money/ai-gateway-pricing) - [How do I pin routing to a specific provider?](/ai-gateway/stay-reliable/pin-a-provider) - [How do I fail over when a provider is slow, not just down?](/ai-gateway/stay-reliable/latency-failover) --- title: "Cut Costs with Caching" description: "Send the same stable prompt prefix twice, compare `cacheReadTokens`, and determine whether caching fits the workload." canonical_url: "https://vercel.com/academy/ai-gateway/prompt-caching" md_url: "https://vercel.com/academy/ai-gateway/prompt-caching.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-20T21:33:01.190Z" content_type: "lesson" course: "ai-gateway" course_title: "Using AI Gateway in Production" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Cut Costs with Caching # How Do I Cut My Token Bill with Caching? The Taco Tuesday assistant sends the full menu and operating instructions with every order. Those repeated input tokens can become a significant part of the request cost even when the content has not changed. Prompt caching lets supported providers reuse that stable prefix instead of charging the full input rate each time. \*\*Note: Quick Answer\*\* Set `caching: "auto"` in `providerOptions.gateway`. The Gateway adds cache markers for providers that require them, while other providers detect repeated prefixes themselves. Verify the cache hit in `usage.inputTokenDetails.cacheReadTokens` and compare the two request costs. ## Outcome Enable automatic caching on the ordering assistant's model and prove, with token counts from two consecutive requests, that the second one read the menu from cache instead of paying full price. ## Fast Track 1. Add `providerOptions: { gateway: { caching: 'auto' } }` to a call with a big stable prefix 2. Run the same request twice 3. Compare `usage.inputTokenDetails.cacheReadTokens`: near zero the first time, nearly the whole menu the second ## Hands-on exercise Build `scripts/cache-check.ts` with two identical requests and print the token details for each one. Requirements: - Use an Anthropic model (`anthropic/claude-sonnet-4.6`), because Anthropic needs the explicit markers, which makes the Gateway's work visible - Send the full `TRUCK_INSTRUCTIONS` (menu included) as `instructions`, plus a short customer question - Set `caching: 'auto'` under `providerOptions.gateway` - After each call, print `inputTokens`, `cacheReadTokens` from `usage.inputTokenDetails`, and the actual cost from `gateway.cost` Caching requires at least two requests to demonstrate a hit. The first request writes the cache entry and may carry a write premium; the second request can read it at the cached-input rate. ## Try It ```bash pnpm cache-check ``` ``` --- Request 1 --- Input tokens: 612 Cache read tokens: 0 Cost: $0.002214 --- Request 2 --- Input tokens: 612 Cache read tokens: 578 Cost: $0.000371 ``` The second request reports most of the stable prefix as cache-read tokens and shows the lower request cost. \*\*Warning: When caching costs you money\*\* Some providers charge more to write a cache entry and less to read it. One-shot prompts can pay the write cost without receiving a later discount. Check the current provider policy and use caching for stable prefixes that repeat. Two issues you may encounter: **`cacheReadTokens` is `0` on the second request.** Check the provider's current cache lifetime and confirm that the stable prefix is byte-identical. A timestamp or request-specific value in the instructions prevents a prefix match. **The second request used another provider.** Cache entries live with a provider, so changing providers can produce a miss. Pin cache-sensitive traffic when consistency matters, as shown in lesson 1.7. ## Commit ```bash git commit -m "feat(caching): enable automatic prompt caching for the menu prefix" ``` ## Done-When - [ ] `scripts/cache-check.ts` runs two requests and prints both receipts - [ ] Request 2 shows `cacheReadTokens` covering most of the menu - [ ] Request 2's actual cost is visibly smaller than request 1's - [ ] You can say when `caching: 'auto'` would *lose* money (one-shot traffic) ## Solution ```ts filename="scripts/cache-check.ts" import { generateText } from "ai"; import { TRUCK_INSTRUCTIONS } from "../lib/menu"; async function order(label: string) { const result = await generateText({ model: "anthropic/claude-sonnet-4.6", instructions: TRUCK_INSTRUCTIONS, prompt: "Which taco should I get if I can't handle spice? Be honest.", providerOptions: { gateway: { caching: "auto" }, }, }); const { inputTokens = 0, inputTokenDetails } = result.usage; const cost = result.finalStep.providerMetadata?.gateway?.cost; console.log(`--- ${label} ---`); console.log(`Input tokens: ${inputTokens}`); console.log(`Cache read tokens: ${inputTokenDetails?.cacheReadTokens ?? 0}`); console.log(`Cost: $${cost}`); } await order("Request 1"); await order("Request 2"); ``` The second request's token metadata tells us whether the provider reused the prefix and how that changed the cost. ## Related Questions - [How is AI Gateway priced?](/ai-gateway/save-money/ai-gateway-pricing) - [How do I pin routing to a specific provider?](/ai-gateway/stay-reliable/pin-a-provider) - [How do I see exactly where my money is going?](/ai-gateway/stay-aware/see-your-spend) --- title: "Use Your AI Credits" description: "See how monthly and purchased Gateway credits apply automatically, and distinguish them from credits held in a provider account." canonical_url: "https://vercel.com/academy/ai-gateway/use-ai-credits" md_url: "https://vercel.com/academy/ai-gateway/use-ai-credits.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-20T21:33:01.208Z" content_type: "lesson" course: "ai-gateway" course_title: "Using AI Gateway in Production" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Use Your AI Credits # How Do I Use AI Credits I Already Bought? AI Gateway Credits are prepaid and automatically fund eligible Gateway requests. Start by confirming which balance you have and which traffic uses it. We will find the balance, run one request, and verify the charge in its request record. \*\*Note: Quick Answer\*\* Monthly and purchased AI Gateway credits appear in the Gateway balance and apply to eligible requests automatically. Credits held in an OpenAI, Anthropic, or other provider account require BYOK, which sends matching requests through your provider credential. Gateway credits and provider credits belong to different accounts. Gateway credits fund requests billed through Vercel. Provider credits apply when a BYOK credential sends the charge to that provider account. ## Outcome Find your credit balance, make a request, and confirm the exact cost of that request came out of the balance. ## Fast Track 1. Open **AI Gateway** in the Vercel dashboard and locate the balance 2. Run `pnpm cost-check` from the demo app 3. Refresh the usage view and match the charge to the script's receipt ## Hands-on exercise Use the `cost-check` script from lesson 2.1 as a known request: - Note whether you're on free credits or purchased credits (the balance display says which) - Run `pnpm cost-check` and keep the `Actual` line from its receipt - Find that request in the usage view and confirm the charge matches The first credit purchase ends eligibility for monthly credits. Review the current tier details, auto top-up setting, and budget controls before adding funds. \*\*Note: Auto top-up exists, and it's off by default\*\* Auto top-up can refill the balance after it falls below a threshold. Review this setting with the budget controls in the next lesson before enabling it. ## Try It After running `pnpm cost-check`: ``` Estimated: $0.000067 Actual: $0.0000672 ``` Refresh the Usage view in the dashboard. The request should appear with the same cost as the script's `Actual` line and be reflected in the credit balance. Two issues you may encounter: **The request returns `429`.** Free-tier limits apply per model. Wait, retry, or use another currently eligible model. **The model errors instead of answering.** The free tier covers a subset of the catalog. If `openai/gpt-5.4-mini` isn't in it right now, pick any model from the free tier list; the balance mechanics are identical. ## Bookmark It No code changed here. Bookmark the **AI Gateway** dashboard so the balance, usage, and request records are easy to find. ## Done-When - [ ] You know your current balance and whether it's free or purchased credits - [ ] You matched one request's receipt to its charge in the usage view - [ ] You can explain the difference between AI Gateway Credits and provider credits without looking - [ ] Auto top-up is in the state you chose on purpose (probably off, for now) ## Solution The work happened in the dashboard: 1. **AI Gateway** tab → balance (upper right). Free or purchased is labeled on the balance display. 2. Balance button → top-up dialog (purchase amounts, payment, and the auto top-up toggle live here). 3. Usage view → per-request charges, matching `providerMetadata.gateway.cost` from your code. The request record is the reliable confirmation for a small charge that may not change a rounded balance display. ## Related Questions - [How do I set a budget so I don't get a surprise bill?](/ai-gateway/save-money/set-a-budget) - [How do I bring my own provider keys?](/ai-gateway/save-money/bring-your-own-keys) - [How is AI Gateway priced?](/ai-gateway/save-money/ai-gateway-pricing) --- title: "Set a Budget" description: "Use prepaid credits as the team-wide boundary, add resettable budgets to workload keys, and account for auto top-up, provider billing, and BYOK fallback in the spend policy." canonical_url: "https://vercel.com/academy/ai-gateway/set-a-budget" md_url: "https://vercel.com/academy/ai-gateway/set-a-budget.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-20T21:33:01.226Z" content_type: "lesson" course: "ai-gateway" course_title: "Using AI Gateway in Production" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Set a Budget # How Do I Set a Budget So I Don't Get a Surprise Bill? A runaway loop can drain a credit balance before anyone notices. Gateway credits put a boundary around team spend, while API key budgets keep one workload from consuming the entire balance. \*\*Note: Quick Answer\*\* AI Gateway Credits are prepaid, so requests stop when the team balance reaches zero. Add a budget to each API key to limit a feature, service, or developer tool, and choose when that budget resets. Key budgets are soft caps: the request that crosses the limit can finish, then later requests are rejected. ## Outcome Give one workload its own API key budget, then document every setting that can refill the balance or move charges to a provider account. ## Fast Track 1. Create or edit a dedicated API key for one workload 2. Set its budget and reset period 3. Review auto top-up, BYOK billing, and system-credential fallback ## Hands-on exercise Open **API Keys** in the AI Gateway dashboard and choose a key used by one workload. Avoid a shared key with five unrelated jobs attached. That key cannot tell you which job spent the money. Set a limit and choose the reset period that matches how your team reviews spend. The key row shows current spend against the limit, which makes it useful for both control and attribution. \*\*Warning: The crossing request completes\*\* A key budget is checked when a request starts. If that request pushes spend past the limit, it still finishes. Requests that start after the limit has been reached are rejected until the budget resets or changes. Now review the settings that sit outside the key budget: - **Auto top-up:** Can refill the team balance automatically - **BYOK provider billing:** Sends successful BYOK charges to the provider account - **BYOK fallback:** Can use Gateway system credentials and Gateway credits when your provider credential fails Record the decision in the demo README: ```markdown ## Spend policy - Workload: order assistant - Gateway key: dedicated key with a monthly reset - Auto top-up: off - BYOK: none configured - Reviewed: , by ``` Use the states your team chose. The example keeps one spending path active and gives the order assistant a budget of its own. ## Try It Confirm three pieces of evidence: 1. The workload key shows spend against its limit 2. The key's edit view shows the intended limit and reset period 3. The README matches the current auto top-up and BYOK settings If requests continue after the limit, confirm that the application is using the budgeted key rather than another team key. If a single request carries the total slightly over the limit, that is the documented soft-cap behavior. ## Commit ```bash git commit -m "docs(spend): record the order assistant budget policy" ``` ## Done-When - [ ] One workload has a dedicated API key with a budget and reset period - [ ] You can explain the soft-cap behavior - [ ] Auto top-up, BYOK billing, and fallback are documented - [ ] The README names an owner and review date ## Solution The solution is a budgeted workload key plus the written spend policy. The dashboard enforces the limit, while the README records the billing paths that the limit does not cover. ## Related Questions - [How do I use AI credits I already bought?](/ai-gateway/save-money/use-ai-credits) - [How do I bring my own provider keys?](/ai-gateway/save-money/bring-your-own-keys) - [How do I see where my money is going?](/ai-gateway/stay-aware/see-your-spend) --- title: "Bring Your Own Keys" description: "Add a provider credential, test it, and inspect routing metadata to see whether a BYOK or system credential served the request." canonical_url: "https://vercel.com/academy/ai-gateway/bring-your-own-keys" md_url: "https://vercel.com/academy/ai-gateway/bring-your-own-keys.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-20T21:33:01.248Z" content_type: "lesson" course: "ai-gateway" course_title: "Using AI Gateway in Production" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Bring Your Own Keys # How Do I Bring My Own Provider Keys? Bring Your Own Key, or BYOK, lets AI Gateway authenticate to model providers with credentials you already manage. The provider bills you under your existing terms, and Vercel adds no markup or fee. BYOK is useful when you have provider credits, negotiated enterprise rates, or models deployed in your own cloud that Gateway system credentials cannot reach. \*\*Note: Quick Answer\*\* Add and test a provider credential in the Gateway BYOK settings. Matching traffic can use that credential, and `credentialType` in the routing metadata identifies whether `byok` or `system` credentials served each attempt. A failed BYOK attempt can fall back to system credentials and use Gateway credits. ## Outcome Add a provider key, and prove from a response's routing metadata exactly whose credentials, yours or the system's, served the request. ## Fast Track 1. **AI Gateway** tab → **Bring Your Own Key (BYOK)** → find your provider → **Add** 2. Enter credentials, leave **Enabled** on, click **Test Key** 3. Run `pnpm whose-key` and read `credentialType` off the receipt ## Hands-on exercise Assume Taco Tuesday already has provider credits with Anthropic. Adding that credential lets matching requests use the provider account while the application keeps its Gateway request path. Requirements: - Add the provider key in the dashboard BYOK section and confirm **Test Key** passes - Build `scripts/whose-key.ts`: one request to a model that provider serves, then walk `routing.modelAttempts[].providerAttempts[]` from the metadata and print each attempt's `provider`, `credentialType`, and `success` - Update your spend policy from lesson 2.5 to include provider-side billing and fallback usage No provider key handy? Run the script anyway. Every attempt will say `credentialType: "system"`, which is the baseline reading, and the script becomes your verification tool the day you do add one. ## Try It ```bash pnpm whose-key ``` With a BYOK key configured for Anthropic: ``` Attempt 1: anthropic (byok) — success Served by: anthropic using YOUR key ``` `credentialType: "byok"` confirms that the provider credential served the successful request. The following block is a previously captured, sanitized fallback trace: ``` Attempt 1: anthropic (byok) — failed: Unauthorized Attempt 2: anthropic (system) — success Served by: anthropic using system credentials ``` Two issues you may encounter: **Every attempt says `system`.** Check that the credential is enabled and that the provider serves the selected model. Use `only` or `order` from lesson 1.7 when traffic must reach the provider that owns the key. **You're on the free tier.** BYOK requires purchased credits, precisely because of the fallback: the Gateway needs a balance to bill when it rescues your failed request with system credentials. ## Commit ```bash git commit -m "feat(byok): add whose-key script to verify credential routing" ``` ## Done-When - [ ] Test Key passes in the dashboard (or you've consciously deferred adding a key) - [ ] `scripts/whose-key.ts` prints `credentialType` for every attempt - [ ] You can explain where the money goes in both the `byok` and fallback cases - [ ] The spend policy reflects the new provider credential and fallback path ## Solution ```ts filename="scripts/whose-key.ts" import { generateText } from "ai"; const result = await generateText({ model: "anthropic/claude-sonnet-4.6", prompt: "One sentence: talk a nervous first-timer into the Birria Eclipse.", }); const routing = result.finalStep.providerMetadata?.gateway?.routing as any; const attempts = routing?.modelAttempts?.flatMap( (m: any) => m.providerAttempts ?? [] ) ?? []; attempts.forEach((a: any, i: number) => { const outcome = a.success ? "success" : `failed: ${a.error}`; console.log(`Attempt ${i + 1}: ${a.provider} (${a.credentialType}) — ${outcome}`); }); const winner = attempts.find((a: any) => a.success); const whose = winner?.credentialType === "byok" ? "YOUR key" : "system credentials"; console.log(`Served by: ${winner?.provider} using ${whose}`); ``` For per-request credentials instead of team-wide ones, the same proof works with the request-scoped option: ```ts providerOptions: { gateway: { byok: { anthropic: [{ apiKey: process.env.ANTHROPIC_API_KEY }], }, }, }, ``` The routing metadata identifies the billing path for each attempt, which belongs in the spend policy from lesson 2.5. ## Related Questions - [How do I set a budget so I don't get a surprise bill?](/ai-gateway/save-money/set-a-budget) - [How do I pin routing to a specific provider?](/ai-gateway/stay-reliable/pin-a-provider) - [How do I use AI credits I already bought?](/ai-gateway/save-money/use-ai-credits) --- title: "See Where Money Goes" description: "Seed synthetic traffic, inspect usage and latency charts, then trace one charge to its project, API key, model, and request record." canonical_url: "https://vercel.com/academy/ai-gateway/see-your-spend" md_url: "https://vercel.com/academy/ai-gateway/see-your-spend.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-20T21:33:01.286Z" content_type: "lesson" course: "ai-gateway" course_title: "Using AI Gateway in Production" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # See Where Money Goes # How Do I See Exactly Where My Money Is Going? A total tells you how much you spent. It does not tell you which model changed, which project got busy, or which request produced the charge. AI Gateway records those dimensions as requests pass through it. We will seed a demo project, read the aggregate charts, and open one request record. \*\*Note: Quick Answer\*\* Use the AI Gateway Usage view to compare requests, tokens, latency, and spend over time. The Requests view can narrow traffic by project or API key and show the cost of an individual request. Add request metadata when the application also needs feature or user attribution. ## Outcome Trace a spend change from a dashboard chart to one synthetic request and its model, project attribution, latency, and cost. ## Fast Track 1. Run `pnpm seed-traffic` from the demo app 2. Open AI Gateway **Usage** and select the current-day range 3. Open **Requests**, filter to the demo project, and inspect one row ## Hands-on exercise Use an isolated demo team or project. The traffic generator gives order chat most of the volume and sends smaller workloads to other models. This creates useful charts without exposing production prompts or customer traffic. Run: ```bash pnpm seed-traffic ``` The script logs each request as it lands, then prints a per-feature summary: ```text order-chat openai/gpt-5.4-mini 35 sent 42,180 in / 3,904 out menu-translation google/gemini-3.5-flash-lite 15 sent 17,865 in / 1,410 out daily-special anthropic/claude-sonnet-4.6 8 sent 9,528 in / 1,102 out ... Seeded 100 of 100 requests across 7 models. Dashboards update within a few minutes; Custom Reporting rows can lag a little longer. ``` Pass a number for a smaller run with the same proportions: `pnpm seed-traffic 25`. On the free tier, a model outside the free subset fails its share of requests; the summary counts them, and the charts still fill in from the rest. ## Try It Wait for the requests to appear, then open AI Gateway in the dashboard. In **Usage**, answer three questions: - Which model handled the most requests? - Which model has the highest time to first token? - Which model contributed the most spend during the selected range? Next, open **Requests**, filter to the demo project, and choose one synthetic row. Confirm its timestamp, model, project or API-key attribution, and cost. The row identifies the request behind the aggregate chart. \*\*Note: Built-in versus custom attribution\*\* Projects and API keys are useful workload boundaries. They cannot identify an end user or feature unless your application sends that metadata. Add `user` and `tags` when you need application-level attribution. If the charts are empty, verify the terminal run succeeded and allow for dashboard ingestion. Do not switch to production traffic just to make the graph interesting. ## Bookmark It Bookmark the AI Gateway **Usage** and **Requests** views. They answer different questions, and production investigations usually move between both. ## Done-When - [ ] The Usage view contains synthetic traffic for at least two models - [ ] You can explain one change in requests, latency, or spend from the chart - [ ] You opened one request and found its model, attribution, and cost - [ ] No production prompts, user IDs, or unrelated request records were exposed ## Solution The complete traffic generator lives at `scripts/seed-traffic.ts`. Its weighted mix sends most requests to inexpensive models and enough traffic to pricier models to make the Spend chart useful. The condensed version below shows the request plan; the full script adds jitter, small concurrent waves, a retry on `429`, and the per-feature summary. ```ts filename="scripts/seed-traffic.ts" import { generateText } from "ai"; import { TRUCK_INSTRUCTIONS } from "../lib/menu"; const total = 100; // Weights are per hundred requests. The full table has seven features; // swap models freely, the dashboards chart whatever shows up. const features = [ { tag: "feature:order-chat", model: "openai/gpt-5.4-mini", weight: 35 }, { tag: "feature:salsa-hotline", model: "deepseek/deepseek-v4-flash-0731", weight: 14 }, { tag: "feature:daily-special", model: "anthropic/claude-sonnet-4.6", weight: 8 }, ]; const plan = features.flatMap((feature) => Array.from( { length: Math.round((feature.weight / 100) * total) }, () => feature, ), ); plan.sort(() => Math.random() - 0.5); for (const feature of plan) { await generateText({ model: feature.model, instructions: TRUCK_INSTRUCTIONS, prompt: "One synthetic taco question, in character.", providerOptions: { gateway: { user: "customer-107", tags: [feature.tag] }, }, }); } ``` Because the traffic is synthetic, every chart and request row is safe to inspect during the exercise. ## Related Questions - [How do I keep my prompts private?](/ai-gateway/stay-aware/keep-prompts-private) - [How do I set a budget?](/ai-gateway/save-money/set-a-budget) - [Why use AI Gateway instead of calling providers directly?](/ai-gateway/stay-reliable/why-ai-gateway) --- title: "Keep Prompts Private" description: "Require no training and zero data retention for one synthetic prompt, then verify how those requirements changed provider eligibility." canonical_url: "https://vercel.com/academy/ai-gateway/keep-prompts-private" md_url: "https://vercel.com/academy/ai-gateway/keep-prompts-private.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-20T21:33:01.306Z" content_type: "lesson" course: "ai-gateway" course_title: "Using AI Gateway in Production" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Keep Prompts Private # How Do I Keep My Prompts Private? Prompts can contain internal instructions, customer context, support history, or the taco recipe that keeps the lunch line moving. Privacy belongs in the routing requirements. AI Gateway can remove providers that do not satisfy the policy before the prompt is sent. \*\*Note: Quick Answer\*\* Set `disallowPromptTraining: true` in `providerOptions.gateway` to exclude providers without a no-training agreement. Add `zeroDataRetention: true` when the request also requires an eligible zero-retention policy. Inspect `routing.planningReasoning`; if no provider qualifies, the request fails with `no_providers_available`. ## Outcome Send one synthetic sensitive prompt with both privacy controls and verify that the routing plan respected them. ## Fast Track 1. Add both privacy fields under `providerOptions.gateway` 2. Run `pnpm private-prompts` 3. Verify `planningReasoning`, or inspect a safe `no_providers_available` failure ## Hands-on exercise Create `scripts/private-prompts.ts`. Use synthetic content; privacy controls are not a reason to put real secrets in a course exercise. ```ts filename="scripts/private-prompts.ts" import { generateText } from "ai"; const result = await generateText({ model: "anthropic/claude-sonnet-4.6", prompt: "Our secret: La Consecuencia gets exactly three ghost peppers, torched, " + "never boiled. Draft one sentence warning customers, without the recipe.", providerOptions: { gateway: { disallowPromptTraining: true, zeroDataRetention: true, }, }, }); const routing = result.finalStep.providerMetadata?.gateway?.routing as any; console.log(result.text.trim()); console.log("---"); console.log(`Served by: ${routing?.finalProvider}`); console.log(`Routing: ${routing?.planningReasoning}`); ``` ## Try It Run it: ```bash pnpm private-prompts ``` A successful answer alone is not proof. Confirm that the output includes the serving provider and planning reasoning. Depending on current model and provider policy, the correct result may instead be `no_providers_available`. That failure is the privacy control doing its job. \*\*Warning: BYOK changes the policy boundary\*\* BYOK traffic runs under your agreement with the provider. Do not assume a Gateway system-credential policy automatically describes retention under your own key. Check the selected model, provider, and credential type together. The two filters compose. The Gateway should not satisfy one by ignoring the other. Model-specific retention exceptions also exist, so re-check the current policy before sending sensitive production data. ## Commit ```bash git commit -m "feat(privacy): require no training and zero data retention" ``` ## Done-When - [ ] Both privacy options are enabled in the request - [ ] The output includes routing planning metadata, or the request safely fails because no provider qualifies - [ ] You can explain why a successful answer without routing evidence is insufficient - [ ] The exercise contains no real secret or customer data ## Solution The script treats privacy requirements as provider filters and keeps routing metadata as evidence. A successful answer without that evidence does not confirm which policy was applied. ## Related Questions - [How do I see where my money is going?](/ai-gateway/stay-aware/see-your-spend) - [How do I pin routing to a provider?](/ai-gateway/stay-reliable/pin-a-provider) - [How do I bring my own provider keys?](/ai-gateway/save-money/bring-your-own-keys) --- title: "Claude Code via Gateway" description: "Point Claude Code at AI Gateway's Anthropic-compatible endpoint, keep its credential outside the project, and give the agent a dedicated key and budget." canonical_url: "https://vercel.com/academy/ai-gateway/claude-code-with-gateway" md_url: "https://vercel.com/academy/ai-gateway/claude-code-with-gateway.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-20T21:33:01.325Z" content_type: "lesson" course: "ai-gateway" course_title: "Using AI Gateway in Production" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Claude Code via Gateway # How Do I Run Claude Code Through the Gateway? Claude Code can use AI Gateway without changing the way you work in the terminal. The connection lives in environment variables, outside the repository. \*\*Note: Quick Answer\*\* Set `ANTHROPIC_BASE_URL` to `https://ai-gateway.vercel.sh`, pass a Gateway key through `ANTHROPIC_AUTH_TOKEN`, and leave `ANTHROPIC_API_KEY` empty. Start Claude Code from that environment and use a dedicated Gateway key with its own budget. ## Outcome Run a harmless Claude Code task through the Gateway configuration without exposing the credential in the project. ## Fast Track 1. Create a dedicated, budgeted Gateway key for Claude Code 2. Configure the three environment variables below 3. Start Claude Code and run a small read-only task ## Hands-on exercise Use a throwaway repository with a small file such as `demo.ts`. Configure the environment before starting Claude Code: ```bash filename="shell configuration" export ANTHROPIC_BASE_URL="https://ai-gateway.vercel.sh" export ANTHROPIC_AUTH_TOKEN="$AI_GATEWAY_API_KEY" export ANTHROPIC_API_KEY="" ``` `ANTHROPIC_AUTH_TOKEN` carries the Gateway key. Leaving `ANTHROPIC_API_KEY` empty prevents Claude Code from selecting that credential path instead. Keep the real token out of shell screenshots, recordings, and source control. A prepared example with `[redacted]` is safer than opening a live shell profile. ## Try It Start Claude Code in the throwaway repository and ask: ```text Explain demo.ts in three bullets. Do not modify anything. ``` Claude Code should answer normally. If it does not connect, confirm that the process inherited all three variables from the shell that launched it. \*\*Note: Using Bedrock or Vertex\*\* When Claude Code routes through Bedrock or Vertex, set `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1`. This prevents Anthropic-specific beta headers from causing errors on those providers. ## Done-When - [ ] Claude Code completes the read-only task - [ ] The Gateway key does not appear in the repository or terminal output - [ ] Claude Code uses a dedicated key with an intentional budget - [ ] You know where the environment configuration is applied ## Solution The complete integration is the three-variable shell configuration above. The repository stays unchanged, and the credential remains in the environment that starts Claude Code. ## Related Questions - [How do I run OpenCode through the Gateway?](/ai-gateway/stay-aware/opencode-with-gateway) - [How do I run Codex through the Gateway?](/ai-gateway/stay-aware/codex-with-gateway) - [How do I set a budget?](/ai-gateway/save-money/set-a-budget) --- title: "OpenCode via Gateway" description: "Connect OpenCode to Vercel AI Gateway, select a model with the built-in model picker, and keep credential entry out of project files and recordings." canonical_url: "https://vercel.com/academy/ai-gateway/opencode-with-gateway" md_url: "https://vercel.com/academy/ai-gateway/opencode-with-gateway.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-20T21:33:01.343Z" content_type: "lesson" course: "ai-gateway" course_title: "Using AI Gateway in Production" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # OpenCode via Gateway # How Do I Run OpenCode Through the Gateway? OpenCode includes Vercel AI Gateway in its provider list. Once the connection is saved, model selection happens in the same terminal interface you already use. \*\*Note: Quick Answer\*\* Connect OpenCode to Vercel AI Gateway once, then open `/models` and choose a model from the Gateway catalog. Enter the credential during setup, keep it outside the repository, and use a dedicated key with a budget. ## Outcome Select a Gateway model in OpenCode and complete one read-only task without exposing the credential. ## Fast Track 1. Connect OpenCode to Vercel AI Gateway off camera or outside shared terminal history 2. Open `/models` and select a Gateway model 3. Run a small task in a throwaway repository ## Hands-on exercise Start with a throwaway repository containing `demo.ts`. Complete the `/connect` flow privately because it opens credential entry. After the connection is saved, open the model picker: ```text /models ``` Choose **Vercel AI Gateway**, then select an available model. The exact catalog changes, so use the picker rather than copying an old model ID from a screenshot. ## Try It Return to the OpenCode chat and ask: ```text Explain demo.ts in three bullets. Do not modify anything. ``` The model should answer in the normal OpenCode interface. `/models` is the safe place to confirm the provider and selection without reopening a credential prompt. If Vercel AI Gateway is missing, repeat the connection flow outside the recording or shared session. If the task fails after model selection, verify that the chosen model is still available to your team. ## Done-When - [ ] `/models` shows Vercel AI Gateway - [ ] A Gateway model is selected - [ ] OpenCode completes the read-only task - [ ] No credential prompt or key value appears in the recording or repository ## Solution OpenCode stores the provider connection through its setup flow. Use `/models` for later model changes, and keep `/connect` for private setup sessions where credential entry will not be captured. ## Related Questions - [How do I run Claude Code through the Gateway?](/ai-gateway/stay-aware/claude-code-with-gateway) - [How do I run Codex through the Gateway?](/ai-gateway/stay-aware/codex-with-gateway) - [How do I bring my own provider keys?](/ai-gateway/save-money/bring-your-own-keys) --- title: "Codex via Gateway" description: "Configure Codex to use Vercel AI Gateway as a custom model provider, select a Gateway model, and keep the credential value out of the Codex configuration file." canonical_url: "https://vercel.com/academy/ai-gateway/codex-with-gateway" md_url: "https://vercel.com/academy/ai-gateway/codex-with-gateway.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-20T21:33:01.361Z" content_type: "lesson" course: "ai-gateway" course_title: "Using AI Gateway in Production" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Codex via Gateway # How Do I Run Codex Through the Gateway? Codex supports custom model providers. A small configuration block gives it the Gateway endpoint and the name of the environment variable that holds your key. \*\*Note: Quick Answer\*\* Add a Vercel provider to the Codex configuration with the Gateway base URL and `AI_GATEWAY_API_KEY` as its `env_key`. Select that provider and a current Gateway model, then start Codex normally. ## Outcome Run one read-only Codex task through a custom AI Gateway provider without storing the key in `config.toml`. ## Fast Track 1. Put the Gateway key in `AI_GATEWAY_API_KEY` 2. Add the provider block below to the Codex configuration 3. Start Codex in a throwaway repository and run a small task ## Hands-on exercise Add a Vercel provider to `~/.codex/config.toml` for a personal default, or to `.codex/config.toml` in a trusted repository for a project-specific setting. Check the current Gateway catalog before choosing a model because model IDs change over time. ```toml filename="config.toml" model_provider = "vercel" model = "openai/gpt-5.6-sol" [model_providers.vercel] name = "Vercel AI Gateway" base_url = "https://ai-gateway.vercel.sh/v1" env_key = "AI_GATEWAY_API_KEY" ``` The file contains the environment variable's name, not its value. Set the real key in the shell or secret manager that starts Codex. ## Try It Open Codex in a throwaway repository containing `demo.ts`, then ask: ```text Explain demo.ts in three bullets. Do not modify anything. ``` Codex should complete the task with the configured provider. If startup fails, verify the TOML structure, confirm that `AI_GATEWAY_API_KEY` exists in the launching environment, and check that the selected model is still in the Gateway catalog. ## Done-When - [ ] Codex starts with the Vercel provider selected - [ ] The selected model exists in the current Gateway catalog - [ ] Codex completes the read-only task - [ ] The key value does not appear in `config.toml`, source control, or terminal output ## Solution The provider block above is the complete Codex configuration. The file identifies the Gateway endpoint and credential variable while the launching environment supplies the secret. ## Related Questions - [How do I run OpenCode through the Gateway?](/ai-gateway/stay-aware/opencode-with-gateway) - [How do I run Claude Code through the Gateway?](/ai-gateway/stay-aware/claude-code-with-gateway) - [How do I see where my money is going?](/ai-gateway/stay-aware/see-your-spend) --- title: "Builders Guide to the AI SDK" description: "Build production-ready AI features with the AI SDK & Next.js. Learn LLMs, prompting, extraction, streaming, & more." canonical_url: "https://vercel.com/academy/ai-sdk" md_url: "https://vercel.com/academy/ai-sdk.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-09-22T04:50:20.512Z" content_type: "course" lessons: 16 estimated_time: lesson_urls: - "https://vercel.com/academy/ai-sdk/introduction-to-llms.md" - "https://vercel.com/academy/ai-sdk/prompting-fundamentals.md" - "https://vercel.com/academy/ai-sdk/ai-sdk-dev-setup.md" - "https://vercel.com/academy/ai-sdk/data-extraction.md" - "https://vercel.com/academy/ai-sdk/model-types-and-performance.md" - "https://vercel.com/academy/ai-sdk/introduction-to-invisible-ai.md" - "https://vercel.com/academy/ai-sdk/text-classification.md" - "https://vercel.com/academy/ai-sdk/automatic-summarization.md" - "https://vercel.com/academy/ai-sdk/structured-data-extraction.md" - "https://vercel.com/academy/ai-sdk/ui-with-v0.md" - "https://vercel.com/academy/ai-sdk/basic-chatbot.md" - "https://vercel.com/academy/ai-sdk/ai-elements.md" - "https://vercel.com/academy/ai-sdk/system-prompts.md" - "https://vercel.com/academy/ai-sdk/tool-use.md" - "https://vercel.com/academy/ai-sdk/multi-step-and-generative-ui.md" - "https://vercel.com/academy/ai-sdk/conclusion.md" --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Builders Guide to the AI SDK The AI SDK is a free open-source library that gives you the tools you need to build AI-powered products. The AI SDK is built and maintained by Vercel, the creators of Next.js. Using the AI SDK will save you hours of frustration and help you ship faster than ever before. The SDK abstracts away the complexity of streaming responses, managing tool calls, and handling provider-specific APIs so you can focus on building features instead of infrastructure. This course is designed to get you building with the AI SDK as quickly as possible, through hands-on examples and practical projects that you can immediately apply to your own work. \*\*Note: Updated for AI SDK v7\*\* This course uses the latest AI SDK v7 patterns, including the `Output.object()` API for structured generation and the `instructions` property for system prompts. ### What you'll actually build (hands-on) **Progressive Script Development:** - **Data Extraction Script**: Compare plain text vs structured output using `generateText` with `Output.object()` - **Classification System**: Batch-process support tickets with categories and urgency using `output: 'array'` - **Summarization with Server Actions**: Build Next.js Server Actions that condense conversations into actionable insights - **Smart Form Parser**: Extract appointment details from natural language with schema refinement **Production Chatbot (built incrementally):** - **Phase 1**: Basic streaming chat with `useChat` and `streamText` - **Phase 2**: Professional transformation with AI Elements - one command to add 20+ production components - **Phase 3**: Personality with system prompts - from generic to Steve Jobs to support bot - **Phase 4**: Real-world data with tool calling - connect to weather APIs with proper validation - **Phase 5**: Advanced workflows with `isStepCount` and custom Weather components instead of debug UI If you'd like to see what's possible with the AI SDK, you can try out [Vercel's Chat SDK](https://chat-sdk.dev/). This is a full-featured open-source AI Chatbot template built using everything you'll learn here (plus a whole lot more). ### How this course teaches This isn't a watch-and-copy tutorial. You'll build everything incrementally: - **Start with broken code** - See why custom solutions are painful - **Discover better patterns** - Learn through progressive improvements - **Use production tools** - AI Elements, Zod schemas, Server Actions - **Debug real issues** - Handle errors, validate schemas, manage tokens - **Ship working features** - Every lesson produces runnable, useful code **Learning with AI assistance:** This course teaches you to build with AI while modeling how to *learn* with AI. When you encounter complex design decisions (schema design, prompt engineering, error handling), you'll find structured prompts you can use with ChatGPT, Claude, or other AI assistants to deepen your understanding. These aren't shortcuts - they're guided exploration tools that help you think through trade-offs and discover solutions independently. ## Prerequisites Before diving in, make sure you're set with: - JavaScript/TypeScript: Comfortable with modern JS syntax and basic TS concepts - React: Familiar with components, hooks, and state management - Git: [Version control system](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) for cloning the starter code - Node.js: [Latest LTS version](https://nodejs.org/en/download) (v22 or later required for AI SDK v7) - pnpm: [Package manager](https://pnpm.io/installation) used throughout the course - Vercel account: [Create one for free](https://vercel.com/signup) The code examples will be written in TypeScript and use React, but they will be step-by-step instructions that you can follow along with. Under the hood the example app is built on Next.js, but it's not a focus and you won't spend time learning any specific Next.js concepts. Note the AI SDK doesn't require Next.js! Everything you'll learn here is portable to any TypeScript project, with or without a framework. ## What you'll build & learn in this course This course is split into three sections: 1. **Foundations**: Build your first AI-powered data extraction script using `generateText()` with structured output via `Output.object()` 2. **Invisible AI**: Build AI features that work behind the scenes - auto-classification, summarization, and structured extraction 3. **Conversational AI**: Build a full-featured chatbot with streaming responses, system prompts, tool calling, and generative UI Each of these sections will build on the previous one, so it's recommended to follow the order. Here are details about each section: ### Section 1: Foundations Build your AI knowledge foundation with: - **LLM Concepts**: Understand how LLMs work as APIs and the power of structured vs unstructured output - **Prompting Techniques**: Practice Zero-Shot, Few-Shot, and Chain-of-Thought in the AI SDK Playground - **Dev Environment Setup**: Configure your project with API keys and proper tooling - **Hands-On Data Extraction**: Build working scripts that demonstrate plain text vs structured output using `Output.object()` - **Model Selection**: Create a comparison tool to understand fast vs reasoning models and their UX impact You'll gain the conceptual understanding and practical tools needed to build production-ready AI features, with clear connections to the invisible AI patterns you'll implement next. ### Section 2: Invisible AI "Invisible AI" means features that work behind the scenes - users don't even know AI is involved, they just see things working better. You'll learn to build structured AI features: - **Smart Classification**: Automatically categorize support tickets, user feedback, and content using `generateText` with `Output.object()` and Zod schemas - **Intelligent Summarization**: Build Next.js Server Actions that condense long conversations into actionable insights - **Data Extraction**: Parse natural language into structured calendar events and forms with schema refinement techniques - **UI Integration**: Use Vercel v0 to generate and integrate professional React components that display your AI-generated data Each lesson builds incrementally with clear TODO guidance, teaching you production patterns for schema evolution, error handling, and real-world deployment. ### Section 3: Conversational AI Build production-ready chat interfaces that combine everything you've learned into interactive AI experiences. This capstone section takes you through the complete chatbot development journey: - **Basic Chat Foundation**: Build streaming chat interfaces using `streamText` and `useChat` with proper error handling - **Professional UI Transformation**: Experience the power of [AI Elements](https://ai-sdk.dev/elements/overview) to instantly upgrade from basic chat to professional interfaces - **Personality & Context**: Implement system prompts to give your AI consistent behavior and voice - **External Integration**: Connect your chatbot to real-world data through tool calling with weather APIs and proper validation - **Advanced Workflows**: Build multi-step conversations and generative UI that renders dynamic React components based on AI responses You'll learn production patterns for error handling, debugging tools, and deployment considerations, ending with a fully functional chatbot that demonstrates enterprise-ready AI features. By the end, you'll know how to ship AI features that actually work. No fluff, just practical patterns you can use immediately. Ready to start building? Let's begin! --- title: "Introduction to LLMs" description: "Learn why treating LLMs like familiar web APIs (input/output, state) accelerates development and how the AI SDK simplifies this builder mindset." canonical_url: "https://vercel.com/academy/ai-sdk/introduction-to-llms" md_url: "https://vercel.com/academy/ai-sdk/introduction-to-llms.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-21T03:14:41.129Z" content_type: "lesson" course: "ai-sdk" course_title: "Builders Guide to the AI SDK" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Introduction to LLMs # Large Language Models: An API for Builders Let's take a look at some code that programmatically generates an LLM response using the AI SDK and OpenAI's gpt-5-mini model: ```typescript import { generateText } from 'ai'; import 'dotenv/config'; async function main() { const { text } = await generateText({ model: 'openai/gpt-5-mini', // Choose your model prompt: 'Tell me a short, funny joke about web developers.', // Give it instructions }); console.log(text); // Get the generation } main().catch(console.error); ``` Pick your model, provide instructions, get text back. Here's an example of how an LLM responds to a simple prompt: This is what you can think of as the LLM-as-API approach and it's a core mental model for working with LLMs programmatically. It's a way of considering LLMs that will help you understand how to use them and start shipping code immediately. Here's how the LLM process works: 1. **LLMs accept input** - You send text (a prompt) to the model 2. **LLMs predict what words come next** - The model uses patterns from its training to generate a response 3. **LLMs send the result** - You get the generated text back That's all you need to understand to get started shipping code. It's the core of what an LLM actually is and what they can do for you. There's a lot more to it of course, so let's dive into some details about what "LLMs are APIs" means in practice. ## LLMs Accept Input As a system, the LLM is always waiting for input to react to. Usually this input comes in the form of a prompt, or a piece of text that you send to the LLM to have it work against to generate a response. The quality of the input greatly affects the quality of the response you get back from the LLM. LLMs have a limit to the amount of input they can receive that is often referred to as a "context limit" or "context window" which represents the total amount of information that the LLM can use to generate a response. Think of it like the model's working memory - it can only "see" a certain amount of text at once. For example: - GPT-4o-mini: \~128,000 tokens (roughly 100,000 words) - GPT-4o: \~128,000 tokens - Claude 3.5 Sonnet: \~200,000 tokens This affects your strategy: if you're building a chatbot, you might need to summarize old messages when the conversation gets too long. If you're analyzing documents, you might need to chunk them into smaller pieces. ## LLMs Predict the Next "Token" LLMs are "just" a fancy autocomplete text generator. They predict what comes next based on patterns they've seen in their training and the current context of their prompt. They break text into "tokens" (words or chunks) and pick the most likely next token. In a library like the AI SDK when you call `generateText()` or `streamText()` to get a response from an LLM you're tapping this prediction engine. Unlike programming a computer where the results are typically deterministic and predictable, LLMs produce probabilistic and often unpredictable outputs. This means that provided the same input, the output the LLM generates can vary widely! Sometimes an LLM will confidently state something that's completely wrong (called "hallucination"). This probabilistic nature means building with LLMs requires different thinking than traditional programming. ### Further Reading: Tokenization – The Building Blocks of LLMs Understanding tokens is crucial for working with LLMs — they impact costs, context windows, and performance. - [OpenAI Tokenizer Tool](https://platform.openai.com/tokenizer) — See how your text gets split into tokens - [Tokens and Context Windows Explained](https://platform.openai.com/docs/guides/text-generation/managing-tokens) — Learn how to work with token limits \*\*Reflection:\*\* After exploring the OpenAI tokenizer tool, what surprised you most about how text gets split into tokens? How will this impact your prompt design and handling of large documents? ## How an LLM learns from the entire Internet LLMs train on massive text dumps (the whole internet + GitHub). The big labs like OpenAI, Anthropic, Meta, and others are scraping the entire internet for every scrap of consumable information that they can feed into training their frontier models. Think pattern recognition at a massive scale. All of them. The model parameter you choose (like `'openai/gpt-5-mini'`) is choosing which pre-trained brain to rent. Bigger models are usually smarter but slower and usually more expensive. Bigger doesn't always mean better and not all models are created equal. They are trained on the same Internet, but ultimately models have sometimes subtle and other times drastic differences. ### Garbage in = garbage out. A model is only as good as the data that it has available, which comes from two primary sources: - The data that the model was trained on - The data that is provided to the model by the user generally referred to as a prompt The quality of the responses you can expect from a model are directly related to the quality of the data that it has access to. These models inherit and retain the biases of their training data. This is important to keep in mind when you are working with their generated responses. When you are creating prompts they need to be focused and contain specific details and instructions to guide the LLM towards generating useful and accurate responses. ## LLMs aren't just parrots If an LLM simply parroted back existing data it would be useless. They follow orders. Your prompt is an extremely important API parameter telling the model what to do. Without your prompt to guide the LLM it's unlikely to produce anything useful. Beyond the generation of text completion, LLMs can be given more decision making responsibility, search the live internet, call tools and apis, and within the context of your instructions provide all sorts of rich detailed information and utility. As you'll see soon, the AI SDK greatly simplifies the messy parts of interacting with an LLM. Asking for simple text is straightforward and the most basic use of an LLM. But if you're building more complex features and functionality in your applications, you'll find many ways to use LLMs. For example, what if you want more structured data based on a schema for validation? The AI SDK provides `generateText()` with `Output.object()` that will produce predictable JSON responses from your prompts. This is very powerful in practice and unlocks a huge variety of use cases. ## Think Through the API: Structured vs Unstructured The key insight is seeing LLMs as APIs that return different types of data. Let's think through this conceptually: **Scenario:** You want to analyze user feedback to improve your product. ### Approach 1: generateText (Unstructured) ```typescript // Conceptual example - you'll build this in lesson 4 const { text } = await generateText({ model: 'openai/gpt-5-mini', prompt: 'Analyze this feedback: "The app crashes when uploading files"', }); // Result: "This appears to be a bug report about file upload functionality..." // Problem: How do you extract the category? Sentiment? Priority? ``` **Challenge:** The response is human-readable text, but your app needs structured data to route tickets, trigger alerts, or update dashboards. ### Approach 2: generateText with Output.object() (Structured) ```typescript // Conceptual example - you'll build this in Section 2 import { generateText, Output } from 'ai'; const responseSchema = z.object({ category: z.enum(['bug', 'feature', 'praise']), sentiment: z.enum(['positive', 'negative', 'neutral']), priority: z.enum(['low', 'medium', 'high']), }); const { output } = await generateText({ model: 'openai/gpt-5-mini', prompt: 'Analyze this feedback: "The app crashes when uploading files"', output: Output.object({ schema: responseSchema }), }); // Result: { category: 'bug', sentiment: 'negative', priority: 'high' } // Benefit: Ready-to-use data for your application logic! ``` **This is the power shift:** From parsing text responses to getting typed, validated data structures. \*\*Note: Coming Up: You'll Build Both\*\* In **Lesson 4: Data Extraction**, you'll write your first working script comparing these approaches. In **Section 2: Invisible AI**, you'll build production features using `generateText` with `Output.object()` for classification, summarization, and data extraction. This conceptual understanding prepares you to choose the right tool for each job! ## Next: The Power of Prompting You've probably heard of "prompt engineering". It's the art and science of giving instructions to an LLM to get the best possible output. That's what we will explore in the next lesson Prompting Fundamentals. --- title: "Prompting Fundamentals" description: "Learn core prompting techniques (Zero-Shot, Few-Shot, Chain-of-Thought) to instruct LLMs. Use the Vercel AI SDK Playground for iteration." canonical_url: "https://vercel.com/academy/ai-sdk/prompting-fundamentals" md_url: "https://vercel.com/academy/ai-sdk/prompting-fundamentals.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-21T03:14:41.170Z" content_type: "lesson" course: "ai-sdk" course_title: "Builders Guide to the AI SDK" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Prompting Fundamentals # The Power (and Nuance) of Prompting Now that you've got a basic understanding of LLMs and how they serve as an API we can dive into the secret sauce - how to actually speak the language of these models to get the results that you want. You do this using prompts. Prompts are the text input that you send to the LLM. Prompting can be powerful, but requires effective techniques to get consistent results. An LLM will respond to any prompt, but all prompts are not created equally. Good prompts can turn an LLM from novelty into a reliable coworker. Think of prompting a model like a chef preparing a meal. Bad ingredients will result in a bad meal. Same with AI: bad prompt = bad output, no matter how fancy your code wrapper. A good prompt is crucial. It's what gets the AI to consistently do what you want. \*\*Note: The Golden Rule of Prompting\*\* **Iterate aggressively. Monitor outputs. Keep tweaking.** Nothing's perfect on the first try. Great prompts come from experimentation. Before diving into techniques, understand the basic anatomy of a good prompt: \*\*Note: Basic Prompt Structure (ICOD)\*\* Good prompts typically contain: - **I**nstruction: What task to do - **C**ontext: Background info - **O**utput Indicator: Format requirements (critical for structured output with `Output.object()`) - **D**ata: The actual input ## 3 Techniques for Prompt Engineering Let's dive into three core techniques every builder needs to know: 1. **Zero-Shot**: Just ask directly without examples 2. **Few-Shot**: Provide examples to guide the output format 3. **Chain-of-Thought**: Break complex problems into steps ### Zero-Shot Prompting: Just Ask! This is the simplest and most common form of prompting: simply asking the model to do something directly, without providing examples. - **Example (Conceptual):** - Prompt: `Classify the sentiment (positive/negative/neutral): 'This movie was okay.'` - Expected Output: `Neutral` - **AI SDK Context:** Great for simple `generateText` calls where the task is common (like basic summarization, Q\&A). Relies heavily on the model's pre-trained knowledge. ```typescript // Simple classification with generateText const { text } = await generateText({ model: 'openai/gpt-5-mini', prompt: `Classify sentiment (positive/negative/neutral): '${userInput}'`, }); // Output might be "Neutral", "neutral", "The sentiment is neutral.", etc. ``` This approach is great for quick for straightforward tasks, but less reliable for complex instructions or specific output formats. ### Few-Shot Prompting: Show, Don't Just Tell For more complex tasks or specific output formats, you need to provide examples *within the prompt* to show the model the pattern or format you want it to follow. #### Example (Fictional Word): ``` Word Definition: Farduddle - To randomly dance vigorously. Word Example: After hearing the news, he started to farduddle uncontrollably. Word Definition: Vardudel - To procrastinate by organizing pencils. Word Example: ``` The model sees the pattern (definition → example) and completes it. This structured approach uses our ICOD framework: ```typescript // Guiding generateText with a few-shot example const { text } = await generateText({ model: 'openai/gpt-5-mini', prompt: ` Classify the following items based on the examples. Item: Apple Category: A Reason: It's a fruit. Item: ${userItem} Category:`, // Model completes based on the pattern }); ``` Providing examples massively improves reliability for specific formats. Clear labels and consistent formatting in examples are key! ### Chain-of-Thought (CoT) Prompting: Think Step-by-Step Mimic human problem-solving by prompting the model to "think out loud" and break down a complex task into intermediate reasoning steps before giving the final answer. #### Example (Odd Numbers Sum): ``` Q: Do the odd numbers in [1, 4, 9, 10, 15, 22, 1] add up to an even number? A: The odd numbers are 1, 9, 15, 1. Their sum is 1 + 9 + 15 + 1 = 26. 26 is an even number. The final answer is: Yes Q: Do the odd numbers in [3, 6, 7, 12, 19, 20, 5] add up to an even number? A: ``` Here's how you would use this style of prompt with the AI SDK: ```typescript // Using CoT prompt structure with generateText const { text } = await generateText({ model: 'openai/gpt-5', // Often better with more capable models prompt: ` Q: Calculate the total cost: 5 apples at $0.50 each, 2 bananas at $0.75 each. A: Cost of apples = 5 * $0.50 = $2.50 Cost of bananas = 2 * $0.75 = $1.50 Total cost = $2.50 + $1.50 = $4.00 The final answer is: $4.00 Q: Calculate the total cost: ${userOrder} A: `, // Model generates steps and answer }); ``` Showing the model "how to think" about the problem improves reliability for logic and complex reasoning. Combine this with few-shot. Remember that this technique **often performs best with more capable models.** ### Core Prompting Advice for Builders Remember this crucial advice: 1. **Be Realistic:** Don't try to build Rome in a single prompt. Break complex application features into smaller, focused prompts for the AI SDK functions. 2. **Be Specific & Over-Explain:** Define *exactly* what you want and don't want. Ambiguity leads to unpredictable results. 3. **Remember the Golden Rule:** Iterate aggressively. Nothing's perfect on the first try - keep testing and refining! ![Ricky Bobby from Talladega Nights saying 'I'm not sure what to do with my hands'](https://hebbkx1anhila5yf.public.blob.vercel-storage.com/CleanShot%202025-03-28%20at%2009.43.01%402x-u7savOv8jlWRCcTbSzn88gqdzcwCu9.png) ## Practice in the AI SDK Playground Before setting up your local environment, let's practice these prompting techniques using the [**AI SDK Playground**](https://ai-sdk.dev/playground). This web-based tool lets you experiment with prompts immediately - no setup required! The playground allows you to: - Compare different prompts and models side-by-side - Adjust parameters like temperature and max tokens - Save and share your experiments - Test structured output with schemas \*\*Note: Why This Practice Matters\*\* The AI SDK Playground lets you experiment with prompting techniques immediately. You're learning patterns that will power the `generateText` calls (with `Output.object()` for structured data) you'll build in upcoming lessons. **Key insight:** Good prompts + structured schemas = reliable AI features in your applications! ### Exercise 1: Few-Shot Prompting Practice Open the [AI SDK Playground](https://ai-sdk.dev/playground) and try this Few-Shot example: **Prompt to try:** ``` Categorize user feedback based on these examples: Example 1: Feedback: "Love the new design! So much easier to navigate." Category: praise, Sentiment: positive, Urgency: low Example 2: Feedback: "Need a dark mode option for night work." Category: feature, Sentiment: neutral, Urgency: medium Example 3: Feedback: "Login page won't load, can't access my account!" Category: bug, Sentiment: negative, Urgency: high Now categorize this feedback: "The app keeps crashing when I try to upload files. This is really frustrating!" ``` **What to observe:** - How the examples guide the AI to follow the same format - The consistency of categorization when you have clear patterns - Try removing the examples and see how the output changes ### Exercise 2: Chain-of-Thought Exploration In the playground, test this Chain-of-Thought prompt: **Prompt to try:** ``` Q: A company has 150 employees. They want teams of 8-12 people, but no team can have exactly 10. Teams should be as equal as possible. How should they organize? A: Let me work through this step by step. First, I need to find valid team sizes: 8, 9, 11, or 12 people. Let me try different combinations: - If I use 12-person teams: 150 ÷ 12 = 12.5, so I could have 12 teams of 12 (144 people) + 1 team of 6. But 6 is too small. - If I use 11-person teams: 150 ÷ 11 = 13.6, so I could have 13 teams of 11 (143 people) + 1 team of 7. But 7 is too small. Let me try mixing sizes... Q: If a small business wants to expand from 5 to 50 employees over 2 years, what should they consider? A: ``` **What to observe:** - How step-by-step reasoning improves complex problem solving - The difference in quality compared to a direct answer - Try the same question without the Chain-of-Thought structure ### Exercise 3: Schema-Guided Structured Output Switch to **structured output mode** in the playground and test this schema: **Schema:** ```json { "type": "object", "properties": { "category": { "type": "string", "enum": ["bug", "feature", "praise", "complaint"] }, "sentiment": { "type": "string", "enum": ["positive", "negative", "neutral"] }, "priority": { "type": "string", "enum": ["low", "medium", "high"] } } } ``` **Prompt:** "Analyze this user feedback: 'Love the new search feature, but it's a bit slow when I type fast.'" \*\*Reflection:\*\* Think about a specific feature you might build using the AI SDK. Which prompting technique (Zero-Shot, Few-Shot, CoT) seems most appropriate and why? How would you iterate on your prompt using the inline tool or the Playground if the initial results weren't what you expected? ## Further Reading (Optional) Prompt engineering is a vast, complex, and ever-evolving topic. Here are some resources to help you dive deeper: - [Prompt Engineering Guide](https://www.promptingguide.ai/) — Community-driven open-source reference covering fundamentals, patterns, pitfalls, and interactive examples. - [The Prompt Report: A Systematic Survey of Prompt Engineering Techniques](https://arxiv.org/abs/2406.06608) — Want a *deep* dive into the vast world of prompt engineering? This comprehensive academic survey categorizes dozens of techniques. Advanced reading if you want to explore beyond the core techniques covered here. - [OpenAI Cookbook – Prompt Engineering Examples](https://github.com/openai/openai-cookbook#prompt-engineering) — Official runnable notebooks showcasing tested prompt patterns and best practices with OpenAI models. - [Anthropic Claude Prompting Guide](https://docs.anthropic.com/en/docs/build-with-claude/prompt-engineering/overview) — Official Claude documentation on prompt structure, guardrails, and safety considerations. - [Anthropic Interactive Prompt Engineering Tutorial](https://github.com/anthropics/prompt-eng-interactive-tutorial) — Free, hands-on, 9-chapter course with exercises and playground demos for mastering Claude prompt engineering. - [Vercel AI Chatbot Template Prompt Examples](https://github.com/vercel/ai-chatbot/blob/main/lib/ai/prompts.ts) — Explore how prompts are structured and used in a complete application. See examples of system prompts and task-specific instructions in the official Vercel AI Chatbot template. Also check the `artifacts/.../server.ts` files! ## Next Step: Setting Up Your AI Dev Environment You've grasped the core prompting techniques and practiced implementing them with the AI SDK. Now it's time to prepare your local machine and set up your development environment with the necessary tools and API keys. The best way to solidify your prompting skills is by building real stuff. Let's get your environment ready so you can go from talking about prompts to implementing them in working code. --- title: "AI SDK Dev Setup" description: "Set up your local dev environment for the Vercel AI SDK: clone repo, install dependencies (pnpm), configure OpenAI API key (.env), and verify setup." canonical_url: "https://vercel.com/academy/ai-sdk/ai-sdk-dev-setup" md_url: "https://vercel.com/academy/ai-sdk/ai-sdk-dev-setup.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-21T03:14:41.194Z" content_type: "lesson" course: "ai-sdk" course_title: "Builders Guide to the AI SDK" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # AI SDK Dev Setup # Project Setup - Get Your Hands Dirty! You've grasped some of the core LLM concepts. Now for the exciting part: setting up your local development environment and getting hands-on with the Vercel AI SDK. This is where you *really* get your hands dirty and prepare to build the cool AI features we've been talking about, like automatic summarization and building intelligent chatbots. You will get the starter project running locally, securely configure the Vercel AI Gateway, and confirm everything works so you're ready to start coding AI in the next lesson. Let's go! \*\*Note: Prerequisites Check\*\* Before starting, ensure you have Git, Node.js (v22+, required for AI SDK v7), and pnpm installed. If you missed any, check the prerequisites section at the beginning of the course for installation links. ## Step 1: Getting the Code First, grab the [starter project code](https://github.com/vercel/ai-sdk-fundamentals-starter). This repository contains all the files and setup needed for the course. Open your terminal (or command prompt) and run this command to clone it: ```bash git clone https://github.com/vercel/ai-sdk-fundamentals-starter.git ``` This copies the project files to your computer. ## Step 2: Navigating Into the Project Now, move into the project directory you just cloned. Use the `cd` (change directory) command: ```bash cd ai-sdk-fundamentals-starter ``` Your terminal prompt should now show you're inside the `ai-sdk-fundamentals-starter` folder. ## Step 3: Installing Dependencies Next, install the necessary libraries (AI SDK, Next.js, etc.). This project uses `pnpm` for fast and efficient package management. Run: ```bash pnpm install ``` \*\*Note: Using npm or Yarn?\*\* While `pnpm` is recommended for this project (due to the lockfile), you can *try* using `npm install` or `yarn install`. Be aware you might encounter slight differences if dependency versions vary. To install pnpm globally, run: `npm install -g pnpm`. This might take a minute or two. ## Step 4: Setting Up the Vercel AI Gateway AI models are accessed through API endpoints and typically this means you need to setup individual accounts and secure API keys for each provider. The [Vercel AI Gateway](https://vercel.com/docs/ai-gateway) simplifies this and allows you to access models from multiple providers through a single endpoint. \*\*Note: Why Use AI Gateway?\*\* - **Free $5 credit:** Get started without needing an OpenAI account or dealing with their credit requirements - **One endpoint, multiple providers:** Switch between OpenAI, Anthropic, Google, and others without code changes - **Automatic failover:** If one provider fails, requests automatically retry with another - **Cost tracking:** Monitor spending across all providers in one place - **Rate limit handling:** Built-in retry logic and request queuing - **Bring your own keys:** Use your existing provider API keys if needed You have two options for authentication with the AI Gateway: \*\*Option A: OIDC Token Setup (Recommended for Projects Already Deployed to Vercel)\*\* This method uses secure OIDC federation and is ideal for projects deployed to Vercel. **1. Install the Vercel CLI** Install the Vercel CLI globally: ```bash pnpm install -g vercel ``` This will allow you to use the `vc` command in your terminal and manage your Vercel projects. **2. Deploy your Project** Deploying your project will both create and deploy your project to Vercel: ```bash vc deploy ``` Note that if you have an existing project already deployed to Vercel, you can use the `vc link` command to link your project. **3. Enable OIDC and Get your Token** Once your project is deployed, enable OIDC in your project settings: 1. Visit your project in the Vercel dashboard 2. Navigate to **Settings → Security** 3. Enable **"Secure Backend Access with OIDC Federation"** Then get your token by running: ```bash vc env pull ``` Check `.env.local` to see your OIDC token: ```bash cat .env.local ``` \*\*Note: OIDC Token Expiration\*\* OIDC tokens are only valid for 12 hours. You have two options: - **Manual refresh:** Run `vc env pull` again to get a new token - **Automatic refresh:** Use `vc dev` to run your application locally - it automatically refreshes the OIDC token when it runs \*\*Option B: API Key Setup (Simpler for Local Development)\*\* If you prefer using a persistent API key instead of OIDC tokens, you can create one directly in the Vercel dashboard: **1. Create an API Key** 1. Go to your [Vercel dashboard](https://vercel.com/dashboard) 2. Navigate to the **AI Gateway** tab 3. Click **"API keys"** in the sidebar 4. Click **"Create key"** and follow the dialog 5. Copy your new API key **2. Configure your Environment** Create or update your `.env.local` file: ```bash # For API key setup AI_GATEWAY_API_KEY=your-api-key-here ``` \*\*Note: Which method should I use?\*\* - **OIDC (Option A):** Best for production deployments and team projects. More secure but requires token refresh. - **API Key (Option B):** Simpler for local development and learning. Persistent but requires careful key management. For this course, either method works fine! ## Step 5: Making Sure It Works! Let's verify your setup. The project has a simple script (env-check.ts) to confirm your authentication is configured correctly. Run it: ```bash pnpm tsx env-check.ts ``` You should see this in your terminal: **If using OIDC (Option A):** ```bash 🔍 Checking environment configuration... ✅ VERCEL_OIDC_TOKEN found - Vercel AI Gateway with OIDC auth Token preview: xxxxxxxxxxxxxxxxxxxxxxxx... ❌ AI_GATEWAY_API_KEY not found 📋 Summary: ✅ Environment is configured correctly! Using: Vercel AI Gateway (OIDC authentication) Note: OIDC tokens expire after 12 hours. Use 'vercel dev' for auto-refresh. ``` **If using API Key (Option B):** ```bash 🔍 Checking environment configuration... ❌ VERCEL_OIDC_TOKEN not found ✅ AI_GATEWAY_API_KEY found - Vercel AI Gateway with API key auth Key preview: xxxxxxxxxxxxxxxxxxxxxxxx... 📋 Summary: ✅ Environment is configured correctly! Using: Vercel AI Gateway (API key authentication) ``` \*\*Note: Token/Key Management\*\* - **OIDC tokens:** Need refreshing every 12 hours. Run `vc env pull` again or use `vc dev` for automatic refresh - **API keys:** Persistent and don't expire, but keep them secure and rotate periodically for best security practices ## Setup Checklist - [ ] Project repository cloned and dependencies installed? - [ ] .env.local file created and API key configured? - [ ] env-check.ts ran successfully and showed your key? - [ ] Ready to start building AI features? ## Common Setup Issues & Troubleshooting \*\*Stuck? Click here for common solutions\*\* - If you get an error about the git command not found, install Git. See the official [Git installation guide](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git). - If you get an error about the pnpm command not found, install pnpm globally. See the official [pnpm installation guide](https://pnpm.io/installation). - If you get an error about the Node.js version, install Node.js. See the official [Node.js installation guide](https://nodejs.org/en/download/package-manager). - `env-check.ts` shows `undefined` or errors: Check your `.env.local` file name (leading dot, no extra extensions). For OIDC, the key name must be `VERCEL_OIDC_TOKEN`. For API Key, it must be `AI_GATEWAY_API_KEY`. Ensure you're running the command from the root of the `ai-sdk-fundamentals-starter` directory. - Other install errors ("module not found", etc.): Delete `node_modules` and `pnpm-lock.yaml`, then run `pnpm install` again. If issues persist, check your Node.js and pnpm versions or seek help. ## Next Up: Your First AI Script Congratulations! Your development environment is set up and ready. Now you'll write and run code that uses the AI SDK to interact with an LLM and perform a practical task: extracting structured data from text. Time to start building. --- title: "Data Extraction" description: "" canonical_url: "https://vercel.com/academy/ai-sdk/data-extraction" md_url: "https://vercel.com/academy/ai-sdk/data-extraction.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-21T03:14:41.222Z" content_type: "lesson" course: "ai-sdk" course_title: "Builders Guide to the AI SDK" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Data Extraction # Extraction - Your First AI Script Now that you've learned some theory and got your project setup, it's time to ship some code. You will build and run a script that extracts info from text using the AI SDK's `generateText` method. This will show you firsthand how tweaking your prompt or swapping models instantly changes your results. ## Analyzing the Starter Script Open your project code. Look for `app/(1-extraction)/extraction.ts` and `essay.txt`. Update the contents of `extraction.ts` with this code that extracts names from the essay: ```typescript title="app/(1-extraction)/extraction.ts" import dotenvFlow from 'dotenv-flow'; dotenvFlow.config(); // Load environment variables (API keys, etc.) import fs from 'fs'; import { generateText } from 'ai'; // AI SDK's core text generation function // Read the essay file that we'll extract names from const essay = fs.readFileSync('app/(1-extraction)/essay.txt', 'utf-8'); async function main() { // Call the LLM with our extraction prompt const result = await generateText({ model: 'openai/gpt-5-mini', // Fast, cost-effective for simple extraction tasks (non-reasoning) // For complex analysis, try 'openai/gpt-5' (reasoning model, slower but more accurate) prompt: `Extract all the names mentioned in this essay. List them separated by commas. Essay: ${essay}`, // Instruction + the actual essay content }); // The AI's response is in result.text console.log('\n--- AI Response ---'); console.log(result.text); // This will be something like: "John Smith, Jane Doe, ..." console.log('-------------------'); } // Run the async function and catch any errors main().catch((error) => { console.error('❌ Extraction failed:', error.message); console.log('\n💡 Common issues:'); console.log(' - Check your .env.local file has valid API keys'); console.log(' - Verify essay.txt exists at app/(1-extraction)/essay.txt'); console.log(' - Ensure you have internet connectivity for API calls'); process.exit(1); }); ``` ## Run Your First AI Script! From your terminal, run: ```bash pnpm extraction ``` You'll see the AI extracting names from the essay. Your first feature works. Nice! ```bash --- AI Response --- Here are all the names mentioned in the essay, separated by commas: Brian Chesky, Ron Conway, Steve Jobs, John Sculley ------------------- ``` \*\*Note: Verification Task\*\* Check `app/(1-extraction)/essay.txt` and use search (Cmd+F/Ctrl+F) to verify the names. Did the AI nail it or miss some? \*\*Note: Understanding Token Usage\*\* LLMs process text as 'tokens' (\~4 chars each). Understanding tokens helps optimize speed and cost: - **Visualize tokenization** at [tiktokenizer.vercel.app](https://tiktokenizer.vercel.app/) - **Count tokens programmatically** with `tiktoken`: `pnpm add tiktoken` - **Monitor usage** to estimate costs and stay within context limits Try pasting different prompts into Tiktokenizer to see surprising patterns (spaces matter!). ## Iteration is Everything Running the script once is just the start. Working with LLMs is all about iteration. Play with the prompt and see for yourself: ### Challenge 1: Prompt Engineering – Change the Task - **Task:** Swap the prompt to the following: ```typescript // Inside the prompt backticks: What is the key takeaway of this piece in 50 words? Essay: ${essay} ``` - **Action:** Save and re-run `pnpm extraction` - **Observe:** See how one prompt change completely transforms what your app does ### Challenge 2: Model Swapping – Upgrade the Brain - **Task:** Keep the summary prompt but change the model using the following code block: ```typescript // Change this line: model: 'openai/gpt-5', ``` - **Action:** Save and run again - **Observe:** Compare results. Better quality? Worth the extra cost/time? \*\*Note: Model Selection Guide\*\* **Available Models via Vercel AI Gateway:** **OpenAI:** - `openai/gpt-5` - Most capable for complex reasoning - `openai/gpt-5-mini` - Fast & cost-effective for most tasks (non-reasoning) - `openai/gpt-5-nano` - Fastest for simple tasks - `openai/gpt-4.1` - Previous generation, still capable (non-reasoning) **Anthropic:** - `anthropic/claude-sonnet-4` - Strong reasoning & analysis **Google:** - `google/gemini-2.5-pro` - Advanced multimodal capabilities - `google/gemini-2.5-flash` - Fast responses, good balance - `google/gemini-2.5-flash-lite` - Lightweight & quick - `google/gemini-2.0-flash` - Previous flash version See the [Vercel AI Gateway models](https://vercel.com/ai-gateway/models) for pricing & details, or the [OpenAI models documentation](https://platform.openai.com/docs/models) for OpenAI-specific info. Simply swap the model string to experiment - the AI SDK handles all the provider differences for you! \*\*Side Quest: Extraction Expert\*\* ```typescript title="advanced-extraction.ts" // Advanced extraction patterns // Company extraction with context const companyPrompt = `Extract all company names from this essay. Include both explicit mentions and implied references (e.g., "the startup" referring to a previously mentioned company). Format as JSON array: ["Company 1", "Company 2"] Essay: ${essay}`; // Concept extraction with categorization const conceptPrompt = `Identify the main business concepts and technical terms in this essay. Categorize them as either 'business' or 'technical' concepts. Format as JSON: { "business": ["concept1", "concept2"], "technical": ["term1", "term2"] } Essay: ${essay}`; // Quote extraction with attribution const quotePrompt = `Extract all quotes (text in quotation marks) from this essay. For each quote, identify who said it if mentioned. Format as JSON array: [{"quote": "text here", "speaker": "name or null"}] Essay: ${essay}`; ``` \*\*Side Quest: Advanced Prompt Engineering\*\* ```typescript title="few-shot-extraction.ts" // Few-shot prompt with examples const fewShotPrompt = `Extract all person names from text, including their roles if mentioned. Example 1: Text: "CEO Sarah Chen met with investor Mark Johnson at the conference." Output: - Sarah Chen (CEO) - Mark Johnson (investor) Example 2: Text: "The founders, Alex and Jamie, hired consultant Dr. Lisa Wang." Output: - Alex (founder) - Jamie (founder) - Dr. Lisa Wang (consultant) Now extract from this text: ${essay} Output:`; // Structured JSON extraction const structuredPrompt = `Extract names and relationships from this essay. Format as JSON with this structure: { "people": [ { "name": "Full Name", "role": "their role/title if mentioned", "relationships": ["list of people they're connected to"] } ] } Essay: ${essay}`; // Consistency testing function async function testConsistency(prompt: string, iterations: number = 3) { const results = []; for (let i = 0; i < iterations; i++) { const result = await generateText({ model: 'openai/gpt-5-mini', prompt }); results.push(result.text); } console.log('Consistency Test Results:'); results.forEach((result, i) => { console.log(`Run ${i + 1}:`, result); }); } ``` \*\*Side Quest: Streaming Extraction Pipeline\*\* ```typescript title="streaming-extraction.ts" export async function extractLargeDocument(filePath: string) { // TODO: chunk file, stream extraction, merge results const chunks = []; const batchSize = 4000; // tokens per chunk // 1. Split document into chunks // 2. Process each chunk with streamText // 3. Merge extracted data // 4. Return consolidated result } ``` ## Real-World Applications This simple extraction pattern powers serious production features like: - **Content Moderation:** Finding problematic content - **Research Tools:** Pulling key data from papers - **Data Pipelines:** Converting messy text to clean data - **Compliance Systems:** Identifying PII/sensitive info It's the same pattern: send content + instructions, process the response. ## Key things to remember - `generateText` = your basic AI workhorse - The `prompt` = what guides the AI - The `model` = power/speed/cost tradeoff - **Iteration** = the key to success \*\*Troubleshooting Guide\*\* API Key Errors (401): Check your `.env.local` file. Key spelled right? Pasted fully? Account has credits? Rate Limiting (429): Hit usage limits. Wait a bit or upgrade your plan. Module Errors: Run `pnpm install` again. Maybe clear `node_modules` first. Timeouts: Larger models are slower. Normal. Check internet if consistent fails. Command not found: Make sure `pnpm` is installed globally and run `pnpm install` in project root. ## Further Reading (Optional) - [**AI SDK Documentation**](https://ai-sdk.dev/docs/ai-sdk-core/generating-text): Official documentation for the core function we used in this lesson. Explore all parameters and options available. - [**Tiktokenizer**](https://tiktokenizer.vercel.app/): Interactive tokenization visualizer built with Next.js. See exactly how your text breaks down into tokens across different models. ([Open source on GitHub](https://github.com/dqbd/tiktokenizer)) - [**Prompt Engineering Guide**](https://www.promptingguide.ai/): Explore advanced prompting techniques to further improve your AI interactions beyond the basics covered in this lesson. - [**Vercel AI Gateway Model Library**](https://ai-sdk.dev/model-library): Understand the capabilities, strengths, cost, and trade-offs of different models to make informed choices for your applications. \*\*Reflection:\*\* What surprised you most when changing prompts vs models? How does this hands-on experience change how you think about working with AI? ## What's Next: Model Types and Performance You've built your first AI script and experienced the power of prompt engineering. In the next lesson, you'll learn about different model types and their performance characteristics. Understanding when to use fast models vs reasoning models is crucial for building AI features that deliver the right user experience. After that, you'll be ready for "invisible AI" - behind-the-scenes features that enhance your product's UX using the patterns you've learned here. --- title: "Model Types and Performance" description: "Learn about different model types in the AI SDK: fast models for immediate responses vs reasoning models for complex problem-solving. Understand when to use each type for optimal user experience." canonical_url: "https://vercel.com/academy/ai-sdk/model-types-and-performance" md_url: "https://vercel.com/academy/ai-sdk/model-types-and-performance.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-21T03:14:41.246Z" content_type: "lesson" course: "ai-sdk" course_title: "Builders Guide to the AI SDK" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Model Types and Performance # Understanding Model Types and Performance Not all AI models are created equal. Some prioritize speed for real-time interactions, while others take time to think through complex problems. Understanding these differences is crucial for building great user experiences. In this lesson, you'll learn about the two main categories of models, fast and reasoning models, and when to use each. \*\*Note: Project Context\*\* We'll compare different model types using simple examples to understand their trade-offs. This knowledge will guide your model choices throughout the course. ## Fast Models vs Reasoning Models The AI SDK gives you access to different types of models, each optimized for different use cases: ### Fast Models (e.g., `openai/gpt-5-nano`, `openai/gpt-5-mini`) **Characteristics:** - Start responding immediately (< 1 second) - Stream tokens as they're generated - Great for real-time interactions - Lower cost per token - Best for straightforward tasks **Best for:** - Chatbots and conversational interfaces - Quick content generation - Simple question answering - Real-time assistance ### Reasoning Models (e.g., `openai/gpt-5.2`, `openai/gpt-5.2-pro`) **Characteristics:** - Think before responding (5-15+ seconds) - More thorough problem analysis - Better at complex reasoning tasks - Higher cost per token - Best for difficult problems **Best for:** - Complex problem solving - Mathematical reasoning - Code analysis and debugging - Multi-step logical tasks - Research and analysis ## Hands-On: Build a Model Comparison Tool Let's create a practical script to experience the differences between fast and reasoning models: ### Step 1: Create the Comparison Script Create `model-comparison.ts` in your project root: ```typescript title="model-comparison.ts" import { generateText } from 'ai'; import 'dotenv/config'; const complexProblem = ` A company has 150 employees. They want to organize them into teams where: - Each team has between 8-12 people - No team should have exactly 10 people - Teams should be as equal in size as possible How should they organize the teams? `; async function compareFastVsReasoning() { // TODO: Test fast model (gpt-5-mini) // - Record start time // - Use generateText with the complex problem // - Calculate and log the response time // - Show first 200 characters of result // TODO: Test reasoning model (gpt-5.2) // - Record start time // - Use generateText with the same problem // - Calculate and log the response time // - Show first 200 characters of result // TODO: Compare the results and timing } // TODO: Call the function to run your comparison // compareFastVsReasoning().catch(console.error); ``` ### Step 2: Implement Fast Model Testing Replace the first TODO with: ```typescript console.log('🚀 Testing fast model (gpt-5-mini)...'); const startFast = Date.now(); const fastResult = await generateText({ model: 'openai/gpt-5-mini', prompt: complexProblem, }); const fastTime = Date.now() - startFast; console.log(`⏱️ Fast model time: ${fastTime}ms`); console.log('📝 Result preview:', fastResult.text.substring(0, 200) + '...\n'); ``` ### Step 3: Implement Reasoning Model Testing Replace the second TODO with: ```typescript console.log('🧠 Testing reasoning model (gpt-5.2)...'); const startReasoning = Date.now(); const reasoningResult = await generateText({ model: 'openai/gpt-5.2', prompt: complexProblem, }); const reasoningTime = Date.now() - startReasoning; console.log(`⏱️ Reasoning model time: ${reasoningTime}ms`); console.log('📝 Result preview:', reasoningResult.text.substring(0, 200) + '...\n'); ``` ### Step 4: Add Comparison Analysis Replace the third TODO with: ```typescript console.log('📊 Performance Comparison:'); console.log(`- Fast model: ${fastTime}ms`); console.log(`- Reasoning model: ${reasoningTime}ms`); console.log(`- Speed difference: ${reasoningTime - fastTime}ms slower for reasoning`); console.log('\n🎯 Key Observations:'); console.log('- Fast models start responding immediately'); console.log('- Reasoning models think before responding'); console.log('- Both solve the problem, but with different approaches'); ``` ### Step 5: Run Your Comparison Uncomment the function call and run: ```bash pnpm tsx model-comparison.ts ``` **What You'll Experience:** - **Fast model**: \~1-3 seconds, streams response immediately - **Reasoning model**: \~10-15 seconds delay, then faster output \*\*Note: Real-World Application\*\* This timing difference directly impacts user experience: - **Fast models**: Perfect for chat interfaces where users expect immediate responses - **Reasoning models**: Better for complex analysis where users can wait for higher quality results Your model choice should match user expectations and use case requirements! **Typical Results:** - **Fast model**: \~1-3 seconds, good answer - **Reasoning model**: \~10-15 seconds, more thorough analysis ## Choosing the Right Model Here's a decision framework for selecting the right model type: **Real-time interactions:** - If complex reasoning is NOT needed → Fast Model (gpt-5-mini) - If complex reasoning IS needed → Consider UX trade-off (fast model + follow-up OR reasoning model + loading UI) **Non-real-time requests:** - If high accuracy is critical → Reasoning Model (gpt-5.2) - If accuracy is less critical → Fast Model (gpt-5-mini) ## Model Selection Guidelines ### Use Fast Models When: - Building chatbots or conversational UI - Users expect immediate responses - Tasks are straightforward - Streaming responses improve UX - Cost efficiency is important ### Use Reasoning Models When: - Complex problem-solving is required - Accuracy is more important than speed - Users can wait for better results - The task benefits from "thinking time" - You can provide good loading states ### Hybrid Approaches: - Start with fast model for immediate response - Offer "detailed analysis" with reasoning model - Use fast model for chat, reasoning for reports - Let users choose based on their needs ## Performance Considerations ### For Streaming Interfaces: **Fast Models:** ```typescript // Visible streaming - tokens appear progressively const result = streamText({ model: 'openai/gpt-5-mini', // Starts immediately messages: [...], }); ``` **Reasoning Models:** ```typescript // Appears to "not stream" due to thinking time const result = streamText({ model: 'openai/gpt-5.2', // 10+ second delay, then fast output messages: [...], }); ``` ### User Experience Tips: **For Reasoning Models:** - Show thinking/loading indicators - Set proper expectations ("This might take 10-15 seconds") - Consider progressive disclosure - Provide cancel options for long requests **For Fast Models:** - Embrace real-time streaming - Keep interfaces responsive - Handle quick back-and-forth conversations ## Cost Considerations Reasoning models typically cost more per token due to their computational requirements: - **Fast models**: Lower cost, faster throughput - **Reasoning models**: Higher cost, better quality for complex tasks Factor this into your application's economics, especially for high-volume use cases. ## Handling Reasoning Model UX When using reasoning models that take time to think, consider these UX patterns: **Loading States:** - Show clear indicators that the AI is thinking - Provide time estimates ("This might take 10-15 seconds") - Consider progress indicators for long operations **Transparency:** - Explain why the delay is happening - Show the AI's reasoning process when appropriate - Let users know they're getting higher quality results **User Control:** - Provide cancel options for long-running requests - Let users choose between fast and thorough responses - Remember user preferences for future interactions ## Real-World Examples ### E-commerce Chatbot - **Fast model** for product questions, order status - **Reasoning model** for complex return policies, compatibility analysis - **Loading states** while reasoning model analyzes recommendations ### Code Editor Assistant - **Fast model** for autocomplete, quick explanations - **Reasoning model** for debugging, architecture reviews - **Progress indicators** during complex code analysis ### Educational Platform - **Fast model** for casual Q\&A, definitions - **Reasoning model** for solving math problems, essay analysis - **Step-by-step display** of problem-solving process ## What You've Learned - **Model Types**: Fast vs reasoning models serve different purposes - **Trade-offs**: Speed vs thoroughness, cost vs quality - **Selection Criteria**: Match model type to use case and user expectations - **UX Considerations**: How model choice affects interface design - **Cost Factors**: Balance performance needs with budget constraints \*\*Reflection:\*\* Think about an AI feature you'd like to build. What type of interactions would it have? Would users expect immediate responses or would they accept a delay for better quality? Which model type would you choose and why? Understanding these model characteristics will help you make informed decisions throughout the rest of the course. In the next section, we'll explore "invisible AI" techniques where model choice significantly impacts user experience. ## Preview: What's Coming in Invisible AI You've learned the fundamentals - now it's time to build features users will love! In the next section, you'll discover how to: **🎯 Transform Text into Structured Data:** - Use `generateText` with `Output.object()` for reliable, typed results - Build smart categorization that sorts support tickets automatically - Create extraction features like calendar event parsing from natural language **⚡ Choose the Right Model for the Job:** - **Fast models (`openai/gpt-5-mini`)** for real-time classification and extraction - **Reasoning models (`openai/gpt-5.2`)** for complex analysis and summarization - Learn when speed vs accuracy matters for user experience **🔧 Practical Patterns You'll Build:** - **Text Classification**: Automatically categorize user feedback, emails, or support requests - **Smart Summarization**: Turn long threads into concise, actionable summaries - **Data Extraction**: Parse natural language into structured calendar events, contacts, or forms These "invisible AI" features work behind the scenes to make your app feel magical - users get better experiences without realizing AI is helping! \*\*Side Quest: Model Router Implementation\*\* ```typescript title="lib/model-router.ts" export interface RouterConfig { task: 'classification' | 'summarization' | 'reasoning'; maxLatencyMs: number; priority: 'cost' | 'quality'; } export function selectModel(config: RouterConfig) { // TODO: return provider/model string, e.g. 'openai/gpt-5-mini' // Hint: combine telemetry with static caps from providers docs return 'openai/gpt-5-mini'; } ``` ## Next Step: Invisible AI Techniques Now that you understand model types, you're ready to explore invisible AI - AI that works behind the scenes to enhance user experiences without requiring direct interaction. --- title: "Introduction to Invisible AI" description: "Section Intro: Explore 'Invisible AI' - features like summarization & classification that improve UX without being the main focus. Prep for building them." canonical_url: "https://vercel.com/academy/ai-sdk/introduction-to-invisible-ai" md_url: "https://vercel.com/academy/ai-sdk/introduction-to-invisible-ai.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-21T03:14:41.291Z" content_type: "lesson" course: "ai-sdk" course_title: "Builders Guide to the AI SDK" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Introduction to Invisible AI # What is Invisible AI? So far you've learned how to call an LLM programmatically and how to iterate on your prompt to perform different tasks (text extraction and summarization). Now let's move from basic scripts to features that create genuine value for users so you'll understand Invisible AI techniques - what they are, why users appreciate them, and how to build them with AI SDK + v0. Let's set aside chatbots for a moment. Some of the most impactful AI features happen when users don't even realize it's there. Smart categorization. Instant summaries. Forms with intelligent assistance. These are the features we're building next. **Invisible AI** is thoughtful enhancements that make your app easier to use. ## Beyond Chatbots: Subtle Superpowers Chatbots get much of the attention (we'll build one later!), but AI's real impact often comes from features users don't explicitly notice. Invisible AI integrates helpful assistance and automation throughout your app and processes. These features don't advertise themselves; they just work. The goal is a seamless experience the user simply appreciates. ### Further Reading: Ethics of Subtle AI When AI is invisible, it raises important ethical considerations about transparency and user agency. - [The Ethics of Invisible AI](https://uxdesign.cc/the-ethics-of-invisible-ai-470732142ee5) — Exploring transparency concerns when AI operates behind the scenes - [Designing Ethical AI Experiences](https://pair.withgoogle.com/guidebook/patterns) — Google's People + AI Research guidebook on ethical AI design patterns - [AI Transparency and User Trust](https://pair.withgoogle.com/guidebook/transparency) — Research on balancing seamless UX with appropriate transparency ## Activation Energy: The Science Behind Great UX You can think about UX as activation energy. Every form field, every click, every search = friction. It adds up over time sapping users of precious energy and attention. Invisible AI done well actively reduces that friction. It automates repetitive tasks and makes complex flows feel intuitive. The needs of the user are **anticipated** without directly asking. For product developers, this approach offers several benefits: less friction = more sign-ups, better conversion rates, fewer support tickets. ## Advancing: From Basic Text to Structured Data In the last lesson, you used `generateText` for basic text generation. For most invisible AI features you'll be using `generateText` with `Output.object()` to generate structured data instead. This approach is powerful and allows you to turn prompts into data that your applications can use. ### See the Difference First, see these examples to understand text vs structured output: Notice how the first returns plain text you'd need to parse, while the second gives you clean, typed JSON ready to use! ### Build Your Own Comparison Now let's build this ourselves. Your template already has starter files with imports and sample data ready. #### Step 1: Implement Text Extraction Open `app/(2-invisible-ai)/test-structured.ts`. Inside the `compareOutputs` function, replace the first TODO with a `generateText` example: ```typescript // Replace the first TODO with this: console.log('\n=== Using generateText (Plain Text) ===\n'); const { text } = await generateText({ model: 'openai/gpt-5-mini', prompt: `Extract all names from this text: ${namesText}`, }); console.log('Raw text output:', text); console.log('Output type:', typeof text); console.log('Need to parse string to get individual names'); ``` #### Step 2: Implement Structured Extraction Now replace the second TODO with `generateText` + `Output.object()`: ```typescript // Replace the second TODO with this: console.log('\n=== Using generateText with Output.object() (Structured Data) ===\n'); const appointmentSchema = z.object({ title: z.string().describe('The meeting title or subject'), date: z.string().describe('The date of the meeting'), time: z.string().nullable().describe('The time of the event'), location: z.string().nullable().describe('Where the event will take place'), attendees: z.array(z.string()).nullable().describe('People attending'), }); const { output } = await generateText({ model: 'openai/gpt-5-mini', prompt: `Parse appointment details from: ${appointmentText}`, output: Output.object({ schema: appointmentSchema }), }); console.log('Structured output:', JSON.stringify(output, null, 2)); console.log('Output type:', typeof output); console.log('\nDirect property access:'); console.log('- Title:', output.title); console.log('- Date:', output.date); console.log('- Time:', output.time); console.log('- Location:', output.location); console.log('- Attendees:', output.attendees?.join(', ')); ``` #### Step 3: Test Your Implementation Run your script to see the difference: ```bash pnpm invisible-ai:compare ``` You'll see: - **generateText** (without output option) returns: "Guillermo, Lee, Sarah" - just a string - **generateText with Output.object()** returns: A typed object with properties you can use directly! ### Build Real Invisible AI Examples Now let's create practical examples. Open `app/(2-invisible-ai)/invisible-ai-demo.ts`. This file has two functions with TODOs for you to implement. #### Step 1: Implement Smart Form Filling In the `smartFormFill` function, replace the TODOs: ```typescript // Replace the TODOs in smartFormFill with: // Define the structure we want const eventSchema = z.object({ eventTitle: z.string().describe('The title or purpose of the event'), date: z.string().describe('The date of the event'), time: z.string().nullable().describe('The time of the event'), duration: z.string().nullable().describe('How long the event will last'), location: z.string().nullable().describe('Where the event will take place'), attendees: z.array(z.string()).nullable().describe('People attending'), notes: z.string().nullable().describe('Additional notes or agenda items'), }); // Extract structured data from natural language const { output: eventDetails } = await generateText({ model: 'openai/gpt-5-mini', prompt: `Extract calendar event details from: "${userInput}"`, output: Output.object({ schema: eventSchema }), }); // Display as if it's a form being auto-filled console.log('✨ AI automatically fills your form:\n'); console.log(`📅 Event: ${eventDetails.eventTitle}`); console.log(`📆 Date: ${eventDetails.date}`); if (eventDetails.time) console.log(`⏰ Time: ${eventDetails.time}`); if (eventDetails.location) console.log(`📍 Location: ${eventDetails.location}`); if (eventDetails.attendees) console.log(`👥 Attendees: ${eventDetails.attendees.join(', ')}`); if (eventDetails.notes) console.log(`📝 Notes: ${eventDetails.notes}`); console.log('\n✅ Form ready to save - no manual input needed!'); ``` #### Step 2: Implement Email Triage (Optional Challenge) Try implementing the `smartEmailTriage` function on your own! Use a similar pattern with `generateText` + `Output.object()` and a Zod schema for email categorization. \*\*Note: 💡 Learning with AI Assistance\*\* **New to these prompts?** Throughout this course, you'll find structured prompts you can copy and use with ChatGPT, Claude, or other AI assistants. These help you explore design decisions (like schema choices, error handling strategies) without just giving you the answer. Think of them as guided discovery tools that model good prompt engineering while helping you learn. **For this challenge:** If you're unsure about when to use `.optional()` vs `.nullable()` in your email triage schema, try this prompt: ```markdown title="Prompt: Understanding Zod Optional vs Nullable" I'm building an email triage feature using the Vercel AI SDK with generateText and Output.object() using Zod schemas. I need to extract fields like priority, category, and suggested response from user emails. const emailSchema = z.object({ category: z.string(), priority: z.string(), suggestedResponse: z.string(), // Should this be optional or nullable? }); What's the difference between `.optional()` and `.nullable()` in Zod schemas? When should I use each for fields that might not have a value? Specifically for my email triage case: - If an email is too complex for auto-response, I want no suggestedResponse field - Should I use `.optional()` or `.nullable()`? - How does this affect the AI's output from generateText with Output.object()? Explain the difference with code examples showing when each should be used. ``` This will help you understand the semantic difference and make your schema more robust! ### Test Your Work Once you've implemented both scripts, run them to see Invisible AI in action: ```bash # First, run your comparison to understand the difference pnpm invisible-ai:compare # Then see your practical examples pnpm invisible-ai:demo ``` \*\*Note: What You've Built\*\* You've just implemented two key Invisible AI patterns: 1. **Text vs Structured Output** - You saw how `generateText` with `Output.object()` gives you typed, ready-to-use data instead of strings you need to parse 2. **Smart Form Filling** - Natural language input automatically populates form fields 3. **Email Triage** (if you did the challenge) - Automatic categorization and prioritization These patterns are the foundation for countless Invisible AI features! ## You're Already Using Invisible AI. It's Everywhere. Invisible AI is everywhere: - Linear suggesting issue titles? AI. - Figma search understanding "blue button"? AI. - Gmail smart replies? AI. - GitHub Copilot? AI. - Analytics showing anomalies? AI. These features don't scream "AI" - they just feel like good UX enhancing, but not interrupting, your workflows. ## A Quick Review - **Invisible AI** = subtle features that make your app easier to use - Reduces UX friction (activation energy) and delights users - Powered by `generateText` with `Output.object()` + Zod schemas for structured data generation \*\*Reflection:\*\* What apps do you use with hidden AI features? Identify 1-2 examples. How do they improve UX? How could you build something similar with the AI SDK? ## Next Up: Building a Classification Engine Ready to build something practical with Invisible AI? You'll start with auto-classification - automatically routing support tickets, content, and user messages into predefined categories. This pattern applies to countless workflows: customer support, content moderation, email triage, feedback analysis. You'll build a system that takes unstructured user text, organizes it into clear categories using Zod schemas for type safety, and significantly improves support workflow efficiency. Let's put `generateText` with `Output.object()` to work! --- title: "Text Classification" description: "Use `generateText` with `Output.array()` and Zod schemas for reliable text classification. Build a tool to automatically categorize user feedback or content." canonical_url: "https://vercel.com/academy/ai-sdk/text-classification" md_url: "https://vercel.com/academy/ai-sdk/text-classification.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-21T03:14:41.314Z" content_type: "lesson" course: "ai-sdk" course_title: "Builders Guide to the AI SDK" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Text Classification # Classification - Structuring Unstructured Data Now that you understand why Invisible AI matters you'll put it in practice with **Classification** - turning messy unstructured text into clean, categorized data. You will use the AI SDK's `generateText` with `Output.array()` and Zod schemas to classify unstructured text into predefined categories. \*\*Note: Project Context\*\* Continuing with the same codebase from [Lesson 1.4](./ai-sdk-dev-setup). For this section, you'll find the classification example files in the `app/(2-classification)/` directory. ## The Problem: Data Chaos Imagine getting flooded with user feedback, support tickets, or GitHub issues. It's a goldmine of information, but it's a messy constant firehose! Manually reading and categorizing everything is slow, tedious, and doesn't scale. - **Support Tickets:** Is it a billing question? A bug report? A feature request? - **User Feedback:** Positive? Negative? A specific feature suggestion? - **GitHub Issues:** Bug? Feature? Docs issue? Needs triage? This is where using LLMs to classify shines. We can teach an LLM our desired categories and have it automatically sort incoming text. ## generateText with Output The AI SDK provides two approaches for working with LLM outputs: **generateText (text only):** Text Input → generateText → Unstructured Text **generateText + Output.object/array:** Text Input + Zod Schema → generateText → Validation → Typed JSON Take a look at the `support_requests.json` file from our project (`app/(2-classification)/support_requests.json`). It contains typical user messages like: ```typescript title="app/(2-classification)/support_requests.json" [ { id: 1, text: "I'm having trouble logging into my account. Can you please assist?", }, { id: 2, text: "The export feature isn't working correctly. Is there a known issue?", }, // ... more requests ]; ``` Our goal is to automatically assign a category (like *account issues or product issues*) to each request. ## The Solution: `generateText` with `Output.array()` The AI SDK's `generateText` function can produce structured output when you provide an `output` specification. For classifying multiple items, we use `Output.array()` which tells the model to return an array of typed objects. To make this work reliably, we need to tell the LLM exactly what we want to generate. That's where Zod comes in. [Zod](https://zod.dev/) is a TypeScript schema definitions and validation library that gives you a powerful and relatively simple way to provide the LLM with the shape of data that is expected in its output. It's like TypeScript types, but with runtime validation - perfect for ensuring AI outputs match your expectations. Here's a short example of a Zod schema: ```typescript const exampleSchema = z.object({ firstName: z.string(), lastName: z.string(), }); ``` This schema describes the shape of a name object and the type of data expected for its properties. Zod provides many different data types and structures as well as the ability to define custom types. With Zod you can define a schema that includes the request text and the available categories. ## Step 1: Define the Schema with `z.enum` Open `app/(2-classification)/classification.ts`. The file already has imports set up and TODOs to guide you. The first step is to define our categories and the structure we want for each classified item using Zod. The `z.enum()` function is ideal for defining a fixed set of possible categories. Replace the first TODO with this schema definition: ```typescript title="app/(2-classification)/classification.ts" // Define the schema for a single classified request const classificationSchema = z.object({ request: z.string().describe('The original support request text.'), category: z .enum([ 'billing', 'product_issues', 'enterprise_sales', 'account_issues', 'product_feedback', ]) .describe('The most relevant category for the support request.'), }); ``` Now replace the remaining TODOs with the generateText implementation: ```typescript import { generateText, Output } from 'ai'; // Use generateText with Output.array() to get structured output const { output: classifiedRequests } = await generateText({ model: 'openai/gpt-5-mini', // Fast model ideal for classification tasks (low cost, immediate response) // For nuanced edge cases, consider 'openai/gpt-5' (reasoning model) // Prompt combines instruction + stringified data prompt: `Classify the following support requests based on the defined categories.\n\n${JSON.stringify(supportRequests)}`, // Output.array() tells the SDK we expect an array of objects matching our schema output: Output.array({ element: classificationSchema, }), }); console.log('\n--- AI Response (Structured JSON) ---'); // Output the validated, structured array console.log(JSON.stringify(classifiedRequests, null, 2)); console.log('-----------------------------------'); ``` Code Breakdown: - **Imports**: Import `generateText` and `Output` from `ai`, plus `Zod` and our JSON data. - **Schema**: Defines the structure for one classified item using `z.object` and `z.enum`. - **Prompt**: Clear instructions plus the raw data for context. - **generateText Call**: Uses `model`, `prompt`, and `output: Output.array({ element: schema })`. - **Output**: Accesses the validated array via `result.output`. ## Step 2: Run the Script Time to see it in action! In your terminal (project root): ```bash pnpm classification ``` You should get a clean JSON array like this: ```typescript // Terminal Output (Example) [ { request: "I'm having trouble logging into my account. Can you please assist?", category: 'account_issues', }, { request: "The export feature isn't working correctly. Is there a known issue?", category: 'product_issues', }, { request: 'I need help integrating your API with our existing system.', category: 'product_issues', // Note: Model might choose this or another category }, // ... other requests classified ... ]; ``` Success! Structured, usable data instead of messy text. Now you can route billing questions to finance, bugs to engineering, and feature requests to product automatically. That's classification power. ## Step 3: Iteration 1 — Adding Urgency Let's make this even more useful. What if we wanted the AI to estimate the urgency of each request? Easy! Just add it to the schema: ```typescript title="app/(2-classification)/classification.ts" // Update the schema definition const classificationSchema = z.object({ request: z.string().describe('The original support request text.'), category: z .enum(['billing', 'product_issues', 'enterprise_sales', 'account_issues', 'product_feedback']) .describe('The most relevant category for the support request.'), urgency: z .enum(['low', 'medium', 'high']) .describe('The probable urgency of the support request.'), }) // ... rest of the main function remains the same ... ``` Run `pnpm classification` again. You'll now see the urgency field added to each object, with the AI making its best guess (e.g., "high" for API integration help, "medium" for the export feature issue). ## Step 4: Iteration 2 — Handling Multi-Language & Refining with `.describe()` Now, let's throw a curveball: `support_requests_multilanguage.json`. This file has requests in Spanish, German, Chinese, etc. Can your setup handle it? ### Challenge: Modify `classification.ts`: ```typescript title="app/(2-classification)/classification.ts" import supportRequests from './support_requests_multilanguage.json'; import { z } from 'zod'; import { generateText, Output } from 'ai'; // Define the schema for a single classified request const classificationSchema = z.object({ request: z.string().describe('The original support request text.'), category: z .enum([ 'billing', 'product_issues', 'enterprise_sales', 'account_issues', 'product_feedback', ]) .describe('The most relevant category for the support request.'), urgency: z .enum(['low', 'medium', 'high']) .describe('The probable urgency of the support request.'), language: z.string(), }); // ... rest of the main function remains the same ... ``` - Change the import: `import supportRequests from './support_requests_multilanguage.json';` - Add `language: z.string()` to the classificationSchema. - Run `pnpm classification`. You'll see the AI detects the languages, but maybe gives you codes ("ES"). We want full names ("Spanish"). This requires more instructions to make it precise. You might think to update the prompt itself, but we are going to update the schema to better indicate what data is expected. Solution: Use `.describe()` to prompt the model for exactly what you want for any specific key! Update the language field in your schema to include a description of what is expected in the field: ```typescript // Inside classificationSchema language: z.string().describe("The full name of the language the support request is in (e.g., English, Spanish, German)."), ``` Run the script one more time. You should now see full language names. Clean, full language names, thanks to our more specific schema instructions. \*\*Note: What if the AI gets it wrong?\*\* The AI SDK uses your Zod schema to *validate* the LLM's output. If the model returns a category not in your `z.enum` list (e.g., "sales\_inquiry" instead of "enterprise\_sales") or fails other schema rules, `generateText` with `Output` will throw a validation error. This prevents unexpected data structures from breaking your application. You might need to refine your prompt, schema descriptions, or use a more capable model if validation fails often. Iteration is the name of the game. Add fields to your schema incrementally. Use `.describe()` to fine-tune the output for specific fields when the default isn't perfect. This schema-driven approach keeps your AI interactions predictable and robust. As you build more sophisticated classification systems, you'll encounter edge cases and ambiguous inputs. The next callout provides a structured approach to refining your schemas when basic descriptions aren't enough. \*\*Note: 💡 Refining Classification Accuracy\*\* Getting inconsistent or ambiguous categories? Try asking an AI assistant to help refine your approach: ```markdown title="Prompt: Improving Multi-Language Classification" I'm building a support ticket classification system using Vercel AI SDK's generateText with Output.array() and Zod schemas. My system classifies requests into categories: billing, product_issues, enterprise_sales, account_issues, product_feedback. I've added multi-language support and urgency detection. const classificationSchema = z.object({ request: z.string().describe('The original support request text.'), category: z.enum(['billing', 'product_issues', 'enterprise_sales', 'account_issues', 'product_feedback']) .describe('The most relevant category for the support request.'), urgency: z.enum(['low', 'medium', 'high']) .describe('The probable urgency of the support request.'), language: z.string().describe("The full name of the language (e.g., English, Spanish, German)."), }); I'm seeing inconsistent results where: 1. Some ambiguous requests get categorized differently on repeated runs 2. Urgency detection seems overly cautious (marking everything as "medium") 3. Edge cases like billing issues that affect product access are unclear How can I improve my schema and prompt to get more consistent, accurate classifications? 1. Should I add confidence scoring to help identify ambiguous cases? 2. How can I use `.describe()` more effectively to guide urgency detection? 3. For edge cases spanning multiple categories, should I switch to multi-label classification? Provide specific schema improvements and `.describe()` examples that address each issue. ``` This will help you understand strategies for improving classification accuracy and handling edge cases! ## Step 4: Enhancing the UI \*\*Side Quest: Multi-Label Classification Challenge\*\* \*\*Note: 💡 Need Help with Multi-Label Schemas?\*\* Stuck on transforming your single-category enum to multi-label arrays? Try this: ```markdown title="Prompt: Enum to Array Transformation for Multi-Label" I'm working on the Multi-Label Classification Challenge in the Vercel AI SDK course. Currently, my schema uses a single category enum: category: z.enum(['billing', 'product_issues', 'enterprise_sales', 'account_issues', 'product_feedback']) .describe('The most relevant category for the support request.') I need to transform this to allow multiple categories per request, since real support tickets often span multiple areas. For example: "I can't access my premium features after my payment went through" is both billing AND product_issues. 1. How do I change `z.enum()` to allow an array of multiple categories? 2. Should I use `z.array(z.enum(...))` - and if so, what does that actually mean? 3. Do I need to update the `.describe()` to guide the AI on when to assign multiple categories? 4. How can I prevent the AI from assigning too many categories (everything becomes "product_issues")? Show me the transformed schema code and explain how to balance multiple categories without over-assignment. ``` \*\*Side Quest: Real-time Moderation System\*\* ```typescript title="app/api/moderation/route.ts" import { generateText, Output } from 'ai'; import { z } from 'zod'; export async function handleMessage(message: string) { // TODO: classify, score, and route message const { output: classification } = await generateText({ model: 'openai/gpt-5-mini', output: Output.object({ schema: z.object({ severity: z.enum(['safe', 'warning', 'critical']), categories: z.array(z.enum(['spam', 'violence', 'pii', 'other'])), confidence: z.number().min(0).max(1) }), }), prompt: `Classify this message: "${message}"` }); // Route based on severity if (classification.severity === 'critical') { await sendAlert(message, classification); } return classification; } ``` ## Further Reading (Optional) Enhance your schema-validation skills with these resources: - [Zod Documentation](https://zod.dev/)\ Complete reference covering parsing, transforms, custom refinements, and error handling. - [Advanced Schema Validation Patterns](https://github.com/colinhacks/zod#schema-methods)\ Cookbook-style examples for preprocessors, unions, discriminated unions, and more. ## Key Takeaways - Classification uses AI to assign predefined categories to text. - `generateText` with `Output.array()` and a Zod schema is the core AI SDK tool for this. - Use `z.enum([...categories])` to define your classification labels. - Use `Output.array({ element: schema })` to classify multiple items at once. - Use `.describe()` on schema fields to guide the model's output format (like getting full language names). This technique can be used to automate workflows like ticket routing, content moderation, and feedback analysis. ## Up Next: Summarization Essentials You've successfully used `generateText` with structured output to classify text. Now, let's apply similar techniques to another powerful Invisible AI feature: summarization. In the next lesson you'll build a tool that creates concise summaries from longer text inputs, helping users quickly grasp key information. We'll also touch on displaying this neatly in your user interface (UI). --- title: "Automatic Summarization" description: "Implement one-click summarization using `generateText` with `Output.object()`. Create concise summaries on demand with structured outputs." canonical_url: "https://vercel.com/academy/ai-sdk/automatic-summarization" md_url: "https://vercel.com/academy/ai-sdk/automatic-summarization.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-21T03:14:41.339Z" content_type: "lesson" course: "ai-sdk" course_title: "Builders Guide to the AI SDK" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Automatic Summarization # Summarization - Condensing Information Overload You've classified unstructured data with structured output which is very useful. Now you'll tackle information overload. Long threads, dense articles, and feedback need summarization - a "TL;DR" feature to empower your users and reduce the noise of day-to-day work. You will build a summarization feature with `generateText` and `Output.object()` to condense comments. ## Too Much Text, Too Little Time We've all been there. You come back to a Slack channel or email thread with dozens of messages. Reading everything takes time you don't have, but you need the gist. Manual summarization is slow and prone to missing key details. This is a perfect job for AI. We can feed an entire conversation to the LLM and ask it to pull out the most essential information. ## Setup: The Comment Thread App \*\*Note: Project Setup\*\* Continuing with the same codebase from [Lesson 1.4](./ai-sdk-dev-setup). For this section, you'll find the summarization example files in the `app/(3-summarization)/` directory. Navigate to the `app/(3-summarization)/summarization/` directory in your project. 1. **Run the Dev Server:** If it's not already running, start it: `pnpm dev` 2. **Open the Page:** Navigate to `http://localhost:3000/summarization` in your browser. You'll see a simple page displaying a list of comments (loaded from `messages.json`). Our task is to make the "Summarize" button functional. ![Screenshot of the '/summarization' page showing the list of comments and the 'Summarize' button.](https://ezs2ytwtdks5l2we.public.blob.vercel-storage.com/ai-sdk-course-summarization-pre-dqpjHr4CQh8tPaXOjzMOc4duno9iy9.png) ## Step 1: Building the Summarization Action We'll use a Next.js Server Action to handle the AI call. \*\*Note: What are Server Actions?\*\* Next.js Server Actions let you run secure server-side code directly from your React components without manually creating API routes. They're perfect for AI features because they keep your API keys and sensitive logic on the server while providing a seamless developer experience for calling backend functions from the frontend. Learn more about [Server Actions](https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations). \*\*Note: Server Actions vs API Routes\*\* Choosing between Server Actions and API routes depends on how the client needs to talk to your code. Reach for **Server Actions** when the work is triggered by your App Router UI (forms, buttons, optimistic updates) and you want automatic cache revalidation and secure access to secrets without exposing an endpoint. Pick a **Route Handler** when you need a reusable HTTP surface—mobile apps, third-party integrations, webhooks, or anything the browser will `fetch`—or when you are building a dedicated backend-for-frontend layer. See the Next.js docs on [Updating Data](https://nextjs.org/docs/app/getting-started/updating-data) and [Route Handlers](https://nextjs.org/docs/app/getting-started/route-handlers), plus the [Backend for Frontend guide](https://nextjs.org/docs/app/guides/backend-for-frontend) for architecture trade-offs. The AI SDK chat helpers specifically expect a Route Handler at `/app/api/chat` by default, so keep one in place for streaming chat flows—even if you also share mutation logic with Server Actions—learn more in the [AI SDK Next.js App Router quickstart](https://ai-sdk.dev/docs/getting-started/nextjs-app-router). Start with the option that fits your UI flow, and extract shared mutation logic into reusable modules when you need both. 1. **Create `` `actions.ts` ``:** Inside the `app/(3-summarization)/summarization/` directory, create a new file named `actions.ts`. 2. **Start with the basic setup:** ```typescript title="app/(3-summarization)/summarization/actions.ts" 'use server'; import { generateText, Output } from 'ai'; import { z } from 'zod'; // TODO: Define the structure for our summary // Create a Zod schema with these fields: // - headline (string) // - context (string) // - discussionPoints (string) // - takeaways (string) export const generateSummary = async (comments: any[]) => { console.log('Generating summary for', comments.length, 'comments...'); // TODO: Use generateText with Output.object() to create the summary // - Model: 'openai/gpt-5-mini' // - Prompt: Ask to summarize the comments, focusing on key decisions and action items // - Output: Output.object({ schema: yourSchema }) // - Return the generated summary from the 'output' property }; ``` 3. **Now implement the schema and `generateText` call:** ```typescript title="app/(3-summarization)/summarization/actions.ts" {6-11, 15-25} "use server"; import { generateText, Output } from "ai"; import { z } from "zod"; const summarySchema = z.object({ headline: z.string(), context: z.string(), discussionPoints: z.string(), takeaways: z.string(), }); export const generateSummary = async (comments: any[]) => { console.log("Generating summary for", comments.length, "comments..."); const { output: summary } = await generateText({ model: "openai/gpt-5-mini", prompt: `Please summarize the following comments concisely, focusing on key decisions and action items. Comments: ${JSON.stringify(comments)}`, output: Output.object({ schema: summarySchema, }), }); console.log("Summary generated:", summary); return summary; }; ``` `generateText` with `Output.object()` is versatile. You can use it with a Zod schema anytime you need structured JSON output from an LLM, whether for classification, summarization details, or data extraction. ## Step 2: Wiring Up the Frontend Let's connect the button in `page.tsx` to our new server action. The file already has basic state set up. 1. **Add the necessary imports at the top of `page.tsx`:** ```typescript title="app/(3-summarization)/summarization/page.tsx" import { generateSummary } from './actions'; // Import the action import { SummaryCard } from './summary-card'; // Import the UI component // Define the expected type based on the action's return type type Summary = Awaited>; ``` 2. **Add state for the summary (after the existing loading state):** ```typescript title="app/(3-summarization)/summarization/page.tsx" {4} // ... existing code ... export default function Home() { const [loading, setLoading] = useState(false); const [summary, setSummary] = useState(null); // ... existing code ... ``` 3. **Replace the button's onClick handler with the actual implementation and add a loading state:** ```typescript title="app/(3-summarization)/summarization/page.tsx" {6-18, 23} // ... existing code ... ``` 1. **Conditionally Render the Summary:** Add the `SummaryCard` component, displaying it only when the `summary` state has data. ```tsx title="app/(3-summarization)/summarization/page.tsx" {3} // ... existing code ...
{summary && } ``` ## Step 3: Run and Observe (Initial Summary) Check your browser (ensure `pnpm run dev` is active). Click "Summarize". ![Screenshot of the '/summarization' page showing summarized comments](https://ezs2ytwtdks5l2we.public.blob.vercel-storage.com/ai-sdk-course-summarization-complete.png) The initial summary might work, but it could be verbose or unstructured. ## Step 4: Refining with `.describe()` Let's improve the summary using Zod's `.describe()` method in `actions.ts` to give the AI more precise instructions. Update the schema in `actions.ts`: ```typescript title="app/(3-summarization)/summarization/actions.ts" {5, 6-8, 11, 12-14} // Update the summarySchema with detailed descriptions const summarySchema = z.object({ headline: z .string() .describe('The main topic or title of the summary. Max 5 words.'), // Concise headline context: z.string().describe( 'Briefly explain the situation or background that led to this discussion. Max 2 sentences.', // Length guidance ), discussionPoints: z .string() .describe('Summarize the key topics discussed. Max 2 sentences.'), // Focused points takeaways: z.string().describe( 'List the main decisions, action items, or next steps. **Include names** for assigned tasks. Max 2-3 bullet points or sentences.', // Specific instructions! ), }); // ... rest of the generateSummary function ... ``` ![Screenshot of the '/summarization' page with enhanced schema](https://ezs2ytwtdks5l2we.public.blob.vercel-storage.com/ai-sdk-course-summarization-enhance-schema.png) Key changes to the schema code: - Added `.describe()` to every field. - Provided specific guidance on length and content focus (e.g., "Include names"). \*\*Note: Performance Note\*\* Summarizing very long conversations can take time and might hit model context limits or timeouts. For production apps with extensive text, consider techniques like chunking the input or using models with larger context windows. Save `actions.ts`, refresh the browser page, and click "Summarize" again. The output should now be much cleaner and follow your instructions more closely, especially the takeaways with assigned names! \*\*Note: 💡 Constraining Summary Output\*\* Want more control over summary length and format? Try asking an AI assistant: ```markdown title="Prompt: Controlling Summary Length and Structure" I'm building an automatic summarization feature using Vercel AI SDK's generateText with Output.object() and Zod schemas. My current schema has fields: headline, context, discussionPoints, and takeaways. I'm using `.describe()` to guide the AI, but the output is still too verbose. const summarySchema = z.object({ headline: z.string().describe('The main topic or title of the summary. Max 5 words.'), context: z.string().describe('Briefly explain the situation. Max 2 sentences.'), discussionPoints: z.string().describe('Summarize key topics. Max 2 sentences.'), takeaways: z.string().describe('List main decisions and action items. Max 2-3 bullet points.') }); Even with "Max 2 sentences" in the description, the AI is returning: - Summaries that are 4-5 sentences long - Bullet points that span multiple lines - Inconsistent formatting (sometimes paragraphs, sometimes bullets) How can I enforce stricter length constraints and consistent formatting? 1. Should I be more explicit in the prompt itself, not just the schema descriptions? 2. Can I use Zod refinements to validate length after generation? 3. Are there better ways to phrase "Max X sentences" that the AI respects more consistently? 4. For bullet points specifically, how do I ensure the AI uses actual markdown bullets vs paragraphs? Provide concrete code examples showing the techniques that work best. ``` This will help you understand advanced techniques for controlling AI output format and length! ## Key Takeaways - Summarization is a core Invisible AI task for handling information overload - The AI SDK makes it easy to summarize using `generateText` with `Output.object()` and a schema - Structured extraction and summarization are powerful together ### Further Reading: Handling Large Inputs for Summarization Real-world summarization often involves content that exceeds token limits. - [Recursive Summarization Techniques](https://platform.openai.com/docs/guides/prompt-engineering/strategy-recursively-summarize-or-process-long-documents) — OpenAI's guide to summarizing large content - [LangChain Summarization Chains](https://python.langchain.com/docs/use_cases/summarization) — Techniques for summarizing long documents using chunking and map-reduce patterns - [Managing Context Windows and Token Limits](https://platform.openai.com/docs/guides/tokens) — Best practices for working within token limits \*\*Side Quest: Scale to 1000+ Comments\*\* \*\*Note: 💡 Need Help with MapReduce Strategy?\*\* Struggling with how to implement chunking and merging summaries? Try this: ```markdown title="Prompt: Implementing MapReduce for Large-Scale Summarization" I'm working on the "Scale to 1000+ Comments" SideQuest in the Vercel AI SDK course. My current summarization works great for ~50 comments, but I need to handle 1000+ efficiently. I understand I need to use the MapReduce pattern: chunk → summarize each chunk → merge summaries. 1. Split 1000 comments into chunks of 15-20 2. Use generateText with Output.object() to summarize each chunk in parallel 3. Take all chunk summaries and create a "summary of summaries" 1. **Chunking:** How do I decide optimal chunk size? Is 15-20 the right number, or should it be based on token count? 2. **Schema evolution:** Should my chunk-level schema be different from my final summary schema? - Do I want more detail in chunk summaries (to avoid losing info)? - Or should I keep them consistent? 3. **Merging strategy:** When creating the final summary from chunk summaries, how do I: - Deduplicate similar points across chunks? - Maintain chronological context if comments are a conversation? - Aggregate action items without losing attribution? 4. **Parallel processing:** I see the example uses p-limit(5). How do I choose the right concurrency limit? - What's the tradeoff between speed and API rate limits? - Should I handle rate limit errors with exponential backoff? 5. **Error handling:** If one chunk fails to summarize, should I: - Retry that chunk? - Skip it and note the gap in the final summary? - Fail the entire operation? For the "Reduce" step, should my prompt be: "Summarize these chunk summaries into a cohesive final summary" Or should it be more specific like: "Merge these chunk summaries, deduplicating common themes and preserving all unique action items" Recommend an approach with example chunking code and explain trade-offs for each strategy. ``` **1. Chunking Strategy** - Divide comments into manageable groups: ```typescript title="chunked-summary.ts" const commentChunks = []; for (let i = 0; i < comments.length; i += 15) { commentChunks.push(comments.slice(i, i + 15)); } ``` **2. MapReduce Pattern** - Summarize chunks, then merge: ```typescript title="map-reduce-summary.ts" import { generateText, Output } from 'ai'; // Map: Generate individual summaries const chunkSummaries = await Promise.all( commentChunks.map(async (chunk) => { const { output } = await generateText({ model: 'openai/gpt-5-mini', prompt: `Summarize these comments: ${JSON.stringify(chunk)}`, output: Output.object({ schema: SummarySchema }), }); return output; }) ); // Reduce: Create a summary of summaries const { output: finalSummary } = await generateText({ model: 'openai/gpt-5-mini', prompt: `Create final summary from: ${JSON.stringify(chunkSummaries)}`, output: Output.object({ schema: SummarySchema }), }); ``` **3. Progressive Refinement** - Update summary as you process: ```typescript title="progressive-summary.ts" import { generateText, Output } from 'ai'; let currentSummary = { mainTopics: [], sentiment: "neutral", actionItems: [] }; for (const chunk of commentChunks) { const { output: chunkInsights } = await generateText({ model: 'openai/gpt-5-mini', prompt: ` Update this summary with new comments. Current: ${JSON.stringify(currentSummary)} New comments: ${JSON.stringify(chunk)} `, output: Output.object({ schema: SummarySchema }), }); currentSummary = chunkInsights; } ``` **4. Parallel Processing** - Handle multiple chunks simultaneously: ```typescript title="parallel-summary.ts" import pLimit from 'p-limit'; const limit = pLimit(5); // Max 5 concurrent API calls const chunkSummaries = await Promise.all( commentChunks.map(chunk => limit(() => summarizeChunk(chunk))) ); ``` **5. Selective Summarization** - Prioritize important comments: ```typescript title="selective-summary.ts" import { generateText, Output } from 'ai'; import { z } from 'zod'; const { output: classification } = await generateText({ model: 'openai/gpt-5-mini', prompt: `Classify by importance: ${JSON.stringify(comments)}`, output: Output.object({ schema: z.object({ highPriority: z.array(z.number()), mediumPriority: z.array(z.number()), lowPriority: z.array(z.number()), }), }), }); // Only summarize high and medium priority const importantComments = [ ...classification.highPriority.map(i => comments[i]), ...classification.mediumPriority.map(i => comments[i]) ]; ``` ## Next up: Precise Data with Structured Extraction You've classified and summarized so now you're ready to get even more precise by extracting specific details from text using `generateText` with `Output.object()` and refined Zod schemas. This type of invisible AI starts to make mundane form entry a thing of the past. In the next lesson, you'll tackle structured extraction for appointments, handling challenges like relative dates with just a few tweaks. --- title: "Structured Data Extraction" description: "Build structured data extraction using Vercel AI SDK `generateText` with `Output.object()` & Zod. Create features like intelligent forms or data normalization from free text." canonical_url: "https://vercel.com/academy/ai-sdk/structured-data-extraction" md_url: "https://vercel.com/academy/ai-sdk/structured-data-extraction.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-21T03:14:41.362Z" content_type: "lesson" course: "ai-sdk" course_title: "Builders Guide to the AI SDK" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Structured Data Extraction # Structured Extraction for App Enhancement You've used AI to classify and summarize text. Now, get even more precise with **Structured Extraction**. This pulls *specific pieces* of information from unstructured text and places them exactly where needed in your app. Use `generateText` with `Output.object()` and a detailed Zod schema to extract appointment details from natural language input and display them using a v0-prototyped UI. \*\*Note: Project Setup\*\* Continuing with the same codebase from [Lesson 1.4](./ai-sdk-dev-setup). For this section, you'll find the extraction example files in the `app/(4-extraction)/` directory. ## The Problem: Turning Natural Language into Data Imagine typing "Lunch with Sarah next Tuesday at noon at Cafe Central" and having it automatically create a perfect calendar event with all the details filled in. Apps like Fantastical pioneered this kind of natural language processing - it seems like magic, but that's structured extraction in action! The flow: Natural Language Input + Zod Schema → Output.object() → Structured Data (title, attendees, time, location) Manually parsing that input is a nightmare. Regex breaks easily, and complex parsing logic is brittle. But for an LLM? It's a natural fit. Models continue to improve giving better results at a lower cost. ## Setup: The Appointment Extractor App Let's get your environment ready. 1. **Run the Dev Server:** Make sure it's running (`pnpm run dev`). 2. **Open the Page:** Navigate to `http://localhost:3000/extraction`. You'll see a simple UI: an input field to type appointment details and an empty calendar card below it (this is our `CalendarAppointment` component, built with Vercel v0. We'll explore this AI-powered UI generator in the next lesson). ![Screenshot of the '/extraction' page showing the input field and the empty 'CalendarAppointment' card.](https://ezs2ytwtdks5l2we.public.blob.vercel-storage.com/ai-sdk-course-sxtraction-setup.png) ## Step 1: The Extraction Action (**`actions.ts`**) Like before, you will use a Server Action to handle the AI call. 1. **Create `schemas.ts`:** In `app/(4-extraction)/extraction/`, create the file `schemas.ts`. 2. **Start with this basic structure:** ```typescript title="app/(4-extraction)/extraction/schemas.ts" import { z } from 'zod'; // TODO: Define the appointmentSchema with these fields: // - title (string) // - startTime (string, nullable) // - endTime (string, nullable) // - attendees (array of strings, nullable) // - location (string, nullable) // - date (string, required) // TODO: Export a type based on the schema using z.infer ``` 3. **Now implement the schema:** ```typescript title="app/(4-extraction)/extraction/schemas.ts" {3-12} import { z } from "zod"; export const appointmentSchema = z.object({ title: z.string(), startTime: z.string().nullable(), endTime: z.string().nullable(), attendees: z.array(z.string()).nullable(), location: z.string().nullable(), date: z.string(), }); export type AppointmentDetails = z.infer; ``` \*\*Note: Why nullable() instead of optional()?\*\* In our experience, explicitly requiring a field but allowing `null` (`z.string().nullable()`) often yields more reliable results from LLMs than making the field entirely optional (`z.string().optional()`). It forces the model to consider the field and consciously decide if the information is present or not. 4. **Create `actions.ts`:** In `app/(4-extraction)/extraction/`, create the file `actions.ts`. 5. **Start with the basic setup:** ```typescript title="app/(4-extraction)/extraction/actions.ts" 'use server'; import { generateText, Output } from 'ai'; import { appointmentSchema, type AppointmentDetails } from './schemas'; export const extractAppointment = async ( input: string, ): Promise => { console.log(`Extracting from: "${input}"`); // TODO: Use generateText with Output.object() to extract appointment details // - Model: 'openai/gpt-5-mini' // - Prompt: Ask to extract appointment details from the input // - Output: Output.object({ schema: appointmentSchema }) // - Return the extracted details from the 'output' property }; ``` 6. **Now implement the extraction:** ```typescript title="app/(4-extraction)/extraction/actions.ts" {11-20} "use server"; import { generateText, Output } from "ai"; import { appointmentSchema, type AppointmentDetails } from "./schemas"; export const extractAppointment = async ( input: string, ): Promise => { console.log(`Extracting from: "${input}"`); const { output: appointmentDetails } = await generateText({ model: "openai/gpt-5-mini", prompt: `Extract the appointment details from the following natural language input:\n\n"${input}"`, output: Output.object({ schema: appointmentSchema, }), }); console.log("Extracted details:", appointmentDetails); return appointmentDetails; }; ``` ## Step 2: Connecting the Frontend (page.tsx) No you'll make the form work. 1. **Open `app/(4-extraction)/extraction/page.tsx`.** The basic UI is already set up. 2. **Add the necessary imports and state at the top of the file (after the existing imports):** ```typescript title="app/(4-extraction)/extraction/page.tsx" // Add these imports import { extractAppointment } from './actions'; import { type AppointmentDetails } from './schemas'; // Inside the component, add state for the appointment data const [appointment, setAppointment] = useState(null); ``` 3. **Replace the handleSubmit function with the actual implementation:** ```typescript title="app/(4-extraction)/extraction/page.tsx" const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setLoading(true); setAppointment(null); // Clear previous results const formData = new FormData(e.target as HTMLFormElement); const input = formData.get('appointment') as string; try { const result = await extractAppointment(input); setAppointment(result); } catch (error) { console.error('Extraction failed:', error); // TODO: Show error to user } finally { setLoading(false); } }; ``` 4. **Pass the appointment data to the CalendarAppointment component:** Find the line with `` and replace it with: ```typescript ``` The complete `page.tsx` file should look like this: ```typescript title="app/(4-extraction)/extraction/page.tsx" {8-9, 13-15, 18-33, 56} "use client"; import { useState } from "react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { CalendarAppointment } from "./calendar-appointment"; import { extractAppointment } from "./actions"; import { type AppointmentDetails } from "./schemas"; export default function Page() { const [loading, setLoading] = useState(false); const [appointment, setAppointment] = useState( null, ); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setLoading(true); setAppointment(null); // Clear previous results const formData = new FormData(e.target as HTMLFormElement); const input = formData.get("appointment") as string; try { const result = await extractAppointment(input); setAppointment(result); } catch (error) { console.error("Extraction failed:", error); // TODO: Show error to user } finally { setLoading(false); } }; return (
Extract Appointment
); } ``` ## Step 3: Run and Observe (Initial Extraction) Let's test it! Go to `http://localhost:3000/extraction`. Enter: `Meeting with Guillermo Rauch about Next Conf Keynote Practice tomorrow at 2pm at Vercel HQ` Click "Extract Appointment". ![Screenshot of the '/extraction' page showing the initial extraction results](https://ezs2ytwtdks5l2we.public.blob.vercel-storage.com/ai-sdk-course-extraction-finish.png) The initial results might be okay, but not perfect (e.g., title includes names, date is wrong, time format is basic). ## Step 4: Refining with **`.describe()`** - The Key! The initial results might work, but they could be imperfect (e.g., title includes names, date might be wrong, time format is basic). Let's improve our extraction using `.describe()` in our Zod schema. Update your `schemas.ts`: ```typescript title="app/(4-extraction)/extraction/schemas.ts" export const appointmentSchema = z.object({ title: z.string().describe( 'The title of the event. Should be the main purpose, concise, without names. Capitalize properly.' ), startTime: z .string() .nullable() .describe('Appointment start time in HH:MM format (e.g., 14:00 for 2pm).'), endTime: z.string().nullable().describe( 'Appointment end time in HH:MM format. If not specified, assume a 1-hour duration after startTime.' ), attendees: z.array(z.string()).nullable().describe( 'List of attendee names. Extract first and last names if available.' ), location: z.string().nullable(), date: z.string().describe( `The date of the appointment. Today's date is ${new Date().toISOString().split('T')[0]}. Use YYYY-MM-DD format.` ), }); ``` Key refinements: - **Title**: Clear instructions to exclude names and be concise - **Time**: Specific format requirements (24-hour HH:MM) - **Date**: Provides today's date for correct relative date calculation - **Attendees**: Instructions on extracting full names Save `schemas.ts`, refresh the browser, and test again with the same input. The extraction should now be much more accurate! ![Screenshot of the '/extraction' page showing the refined extraction results](https://ezs2ytwtdks5l2we.public.blob.vercel-storage.com/ai-sdk-course-extraction-refined.png) \*\*Note: 💡 Handling Relative Dates and Time Formats\*\* Struggling with date parsing or time format inconsistencies? Try asking an AI assistant: ```markdown title="Prompt: Improving Date and Time Extraction Accuracy" I'm building an appointment extraction feature using Vercel AI SDK's `generateText` with `Output.object()` and Zod schemas. My schema extracts: title, startTime, endTime, attendees, location, and date. I'm using `.describe()` to guide the AI, including providing today's date for context. export const appointmentSchema = z.object({ title: z.string().describe('The title of the event. Should be the main purpose, without names.'), startTime: z.string().nullable().describe('Appointment start time in HH:MM format (e.g., 14:00 for 2pm).'), endTime: z.string().nullable().describe('Appointment end time in HH:MM format. If not specified, assume 1-hour duration.'), attendees: z.array(z.string()).nullable().describe('List of attendee names.'), location: z.string().nullable(), date: z.string().describe(`The date of the appointment. Today's date is ${new Date().toISOString().split('T')[0]}. Use YYYY-MM-DD format.`) }); 1. **Relative dates are inconsistent:** - "tomorrow" sometimes calculates correctly, sometimes returns today - "next Tuesday" occasionally picks the wrong week - "in 3 days" sometimes fails entirely 2. **Time formats vary:** - Sometimes get "2pm" instead of "14:00" - "2:30pm" becomes "2:30" (missing hour padding) - Ambiguous times like "morning" or "afternoon" return null 3. **Missing endTime logic:** - Even with "assume 1-hour duration" in description, endTime often stays null 1. Should I provide more context in the date description? Like day of week for today? 2. For time format enforcement, should I use Zod `.regex()` or `.refine()` to validate HH:MM format? 3. How can I make the AI more reliably calculate endTime when not specified? 4. Would it help to include example inputs/outputs in the schema descriptions? Input: "Quick sync with Lee tomorrow morning" Expected: date=2025-09-30, startTime="09:00", endTime="10:00" Actual: date=2025-09-29 (wrong), startTime=null, endTime=null Show me the improved schema with context injection that fixes this specific case. ``` This will help you understand advanced techniques for date/time context injection and format validation! ## Key Things to Consider - Structured Extraction pulls specific data points from unstructured text into a defined format. - `generateText` with `Output.object()` + Zod Schema is the ideal tool combination. - Use `nullable()` for potentially missing fields. - `.describe()` is essential for specifying formats, providing context (like today's date), and defining default logic. - Sharing Zod schemas between backend (actions) and frontend provides end-to-end type safety. \*\*Side Quest: Advanced Date/Time Parsing\*\* ```typescript title="advanced-date-parsing.ts" // Modified schema with date transformation import { z } from 'zod'; import { parseISO } from 'date-fns'; // Install with: pnpm add date-fns const AppointmentSchema = z.object({ title: z.string(), date: z.string() .describe('The appointment date in ISO format (YYYY-MM-DD)') .transform(dateStr => { try { return parseISO(dateStr); } catch (e) { // If parsing fails, return the original string // This allows Zod validation to continue return new Date('Invalid Date'); } }) .refine(date => !isNaN(date.getTime()), { message: 'Invalid date format, must be YYYY-MM-DD' }), time: z.string() .describe('The appointment time in 24-hour format (HH:MM)') .nullable(), location: z.string().nullable(), attendees: z.array(z.string()).nullable(), }); // Enhanced prompt that emphasizes date format requirements const prompt = ` Extract appointment details from this text. ALWAYS format dates as ISO strings (YYYY-MM-DD), converting relative dates like "tomorrow" or "next Friday" to actual calendar dates based on today being ${new Date().toISOString().split('T')[0]}. Text: "${appointmentText}" `; ``` \*\*Side Quest: Extraction Validation Pipeline\*\* ```typescript title="lib/extraction-validator.ts" export interface ValidationResult { isValid: boolean; confidence: number; errors: Array<{ field: string; message: string }>; warnings: Array<{ field: string; suggestion: string }>; } export async function validateExtraction( payload: unknown, schema: z.ZodSchema ): Promise { // TODO: run staged validation and return confidence score // 1. Syntax validation with Zod // 2. Business rules checks // 3. External API validation // 4. Confidence scoring return { isValid: false, confidence: 0, errors: [], warnings: [] }; } ``` ## Next Step: Supercharge UI with Vercel v0 You've seen how structured data unlocks practical features like calendar extraction and form filling. Now, take a quick (optional) detour to explore Vercel v0, the tool that was used to prototype the `CalendarAppointment` UI in this example. You'll get hands-on experience generating UI components directly from prompts, accelerating your frontend development for AI features. --- title: "UI with v0" description: "Learn how Vercel v0 accelerates UI development for AI features. Generate React components (using Shadcn UI & Tailwind) directly from text prompts." canonical_url: "https://vercel.com/academy/ai-sdk/ui-with-v0" md_url: "https://vercel.com/academy/ai-sdk/ui-with-v0.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-21T03:14:41.385Z" content_type: "lesson" course: "ai-sdk" course_title: "Builders Guide to the AI SDK" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # UI with v0 # \[Bonus] Supercharge UI with Vercel v0 You've learned how to interact with LLMs using the AI SDK. That's great, but your users need an excellent UI. This is where v0 comes in. v0 turns text prompts into well structured, fully styled React components in seconds. From basic prototypes to full-featured applications deployed to the web, v0 quickly brings your UI vision into reality. Go from text prompt to polished UI components without CSS hell. Get slick interfaces for your AI features without design skills. \*\*Note: Project Context\*\* This uses [v0.app](https://v0.app) as an external tool. The same project setup from earlier applies when integrating your fancy new components. ## What is Vercel v0? v0 is a powerful application building agent that takes your natural language prompts and converts them into fully functioning deployable applications. Feed it a prompt, get back production-ready React code. It's trained on best practices for React, Tailwind and shadcn/ui. \*\*Note: About shadcn/ui\*\* You've been using shadcn/ui components like `Card` and `Button` in previous lessons. It's a popular component library that provides copy-and-paste React components built with Radix UI and Tailwind CSS. v0 generates components using this same system, so everything integrates seamlessly. Learn more at [ui.shadcn.com](https://ui.shadcn.com/). v0 removes a lot of the initial pixel-pushing and CSS debugging, letting you focus on functionality. ## Generate a Component with v0 Let's build a card component to show that structured appointment data from the last lesson. Head to [v0.app](https://v0.app) and try this prompt: > Create a React card component using Tailwind CSS and shadcn/ui Card components. It should accept props: title (string), date (string), time (string, nullable), location (string, nullable). Display title prominently. Show date/time and location below, each with a simple icon (calendar, clock, map pin). Handle null values gracefully with placeholder text like 'Not specified'. [Open in v0]() ## Iterating on Prompts Small tweaks in prompts can mean big UI changes and much quicker turn around from idea to deployment: **Weak Prompt:** ``` Create an appointment card. ``` **Strong Prompt:** ``` Create a calendar appointment card using shadcn/ui Card component, with a blue background, white text, rounded corners, and slight hover effect. Use this schema: export const appointmentSchema = z.object({ title: z.string(), // Use nullable() for fields that might not be present startTime: z.string().nullable(), endTime: z.string().nullable(), attendees: z.array(z.string()).nullable(), location: z.string().nullable(), // Date is required date: z.string(), }) ``` Giving v0 specific details will result in more refined output that is closer to what we want. Otherwise we make the LLM guess, and the results reflect that. **Give it a try yourself:** Try this prompt at [v0.app](https://v0.app): > Create a React card component using Tailwind CSS and shadcn/ui Card components. Props: title, date, time, location. Show title and details with icons. [Open in v0]() ## Build and Integrate Your Own Component Let's create a component specifically for displaying the structured data from our extraction lesson, then integrate it into your project. ### Step 1: Generate Your Component with v0 Use this prompt at [v0.app](https://v0.app): > Create a SummaryCard React component using shadcn/ui Card components and Tailwind CSS. > > Props interface: > > - headline: string (main title, prominent display) > - context: string (background info, smaller text) > - discussionPoints: string (key topics, formatted as a list) > - takeaways: string (action items, formatted as bullets) > > Design requirements: > > - Clean card layout with subtle border and shadow > - Headline should be large and bold > - Use icons from lucide-react for each section (MessageSquare for context, List for discussion, CheckCircle for takeaways) > - Responsive design that works on mobile > - Light background with good contrast [Open in v0]() ### Step 2: Copy the Generated Component After v0 generates your component: 1. **Copy the entire component code** from v0 2. **Note any dependencies** listed (usually shadcn components and icons) ### Step 3: Set Up Dependencies Check what shadcn components you need and install them: ```bash # Common components you might need (overwrite existing if prompted) pnpm dlx shadcn@latest add card pnpm dlx shadcn@latest add badge # Install lucide-react pnpm add lucide-react ``` \*\*Note: Overwriting Components\*\* If prompted about existing components, choose **Yes** to overwrite. This ensures you have the latest versions that work best with v0-generated code. ### Step 4: Create the Component File Create `components/SummaryCard.tsx` and paste your v0-generated code: ```typescript title="components/SummaryCard.tsx" // TODO: Paste your v0-generated component code here // Make sure to include all imports at the top interface SummaryCardProps { headline: string; context: string; discussionPoints: string; takeaways: string; } export function SummaryCard({ headline, context, discussionPoints, takeaways }: SummaryCardProps) { // TODO: Paste the component JSX from v0 here return (
Your v0 component goes here
); } ``` ### Step 5: Test Integration Replace the existing SummaryCard in your summarization page with your new v0-generated component: ```typescript title="app/(3-summarization)/summarization/page.tsx" // Update the import to use your new component import { SummaryCard } from '@/components/SummaryCard'; // The rest of your code remains the same ``` ### Step 6: Verify It Works 1. **Run your dev server**: `pnpm dev` 2. **Navigate to**: `http://localhost:3000/summarization` 3. **Click "Summarize"** and see your custom v0 component in action! \*\*Note: What You've Accomplished\*\* You've now experienced the full v0 workflow: 1. **Prompt Engineering**: Crafted a specific prompt with requirements 2. **Component Generation**: Let v0 create professional React code 3. **Integration**: Added the component to your existing AI-powered app 4. **Testing**: Verified it works with real AI-generated data This is exactly how you'd use v0 in production - quickly generate UI components and integrate them with your AI SDK features! ## Integrating v0 Code The code v0 creates is ready to ship. You can often just cut and paste into your app. Here's how to use it: 1. **Copy Code:** Grab everything including imports 2. **Install Dependencies:** Often this means shadcn components, which are added like this: ```bash # Example if Card pnpm dlx shadcn@latest add card ``` 3. **Create Component File:** Drop it in `components/GeneratedCard.tsx` 4. **Import & Use:** Pass your data to the new component \*\*Warning: Common Gotchas\*\* - **Missing Components:** Run shadcn CLI for any components in imports - **Style Conflicts:** Global CSS might break things - **Type Mismatches:** Tweak the props interface if needed **Example Generated Code (Simplified, your code will vary):** ```typescript // components/GeneratedCard.tsx import { CalendarIcon, ClockIcon, MapPinIcon } from 'lucide-react' // Ensure lucide-react is installed interface GeneratedCardProps { title: string date: string time?: string | null location?: string | null } export function GeneratedCard({ title, date, time, location }: GeneratedCardProps) { return ( {title}
{date} {time && ( <> {time} )} {!time && (Time not specified)}
{location || Location not specified}
) } ``` Using it: ```typescript // Example usage in a page component export default function MyPage() { const appointmentData = { // Example data title: 'AI Sync Meeting', date: '2025-11-15', time: '14:00', location: 'Virtual', } return (

Upcoming Appointment

) } ``` With v0, you can: - Create beautiful UIs for your AI outputs - Iterate on design through text instead of CSS ## Key Takeaways - **UI-as-Prompts:** Tell v0 what you want, get polished React components - **Ship Faster:** Cut UI dev time by 80%+, focus on AI SDK for low-level heavy lifting - **Dead Simple:** Copy, paste, add dependencies, import, done - **Perfect for AI Results:** Quickly create cards/displays for your AI data It's the speed combo: AI SDK handles the brains, v0 handles the looks. Cuts the UI boilerplate you are required to create by hand dramatically. ## Preview: What's Coming in Conversational AI You've learned invisible AI techniques that work behind the scenes. Now it's time to build direct human-AI interaction that puts users in control! **🚀 From Invisible to Interactive:** The classification, summarization, and extraction patterns you've learned will power your conversational interfaces: - **Smart routing**: Use classification to determine which tools a chatbot should call - **Context summaries**: Automatically summarize long conversations for better AI memory - **Form filling**: Extract structured data from natural language in chat interfaces **💬 Professional Chat Interfaces:** - Build streaming chatbots with `useChat` and `streamText` - Transform basic UIs into professional interfaces with AI SDK Elements - Handle tool calling so your AI can fetch real-time data and perform actions **🛠️ Advanced AI Capabilities:** - **System prompts**: Give your AI consistent personality and behavior - **Tool integration**: Connect your chatbot to APIs, databases, and external services - **Multi-step conversations**: Enable complex workflows with multiple tool calls - **Generative UI**: Render dynamic React components based on AI responses **🎯 The Complete Picture:** By the end, you'll combine invisible AI (working behind the scenes) with conversational AI (direct interaction) to create powerful, user-friendly applications that feel magical to use. ## Now You're Ready to Build Something More Complex You've seen the power of Invisible AI techniques. Now it's time for more direct human-AI interaction. In the next section you will build a full-featured chat interface using the `useChat` hook from the AI SDK, complete with streaming responses, customizable behavior, tool-use, and dynamic UI components. This is the basic loop that you see in tools like Cursor, Claude, ChatGPT, and many other applications that bring the massive power of AI to users' fingertips. --- title: "Basic Chatbot" description: "Use the AI SDK `useChat` hook to build a streaming chatbot interface in Next.js. Experience the complexity of custom UI before discovering a better way." canonical_url: "https://vercel.com/academy/ai-sdk/basic-chatbot" md_url: "https://vercel.com/academy/ai-sdk/basic-chatbot.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-21T03:14:41.425Z" content_type: "lesson" course: "ai-sdk" course_title: "Builders Guide to the AI SDK" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Basic Chatbot # Build a Chatbot You've been using AI behind the scenes for classification, summarization, and extraction. Now let's build something that everyone recognizes; a ChatGPT-style conversational interface. Over the next five lessons, you'll start with the fundamentals of streaming chat, then progressively add the features that make these interfaces powerful: professional UI components, system prompts for personality, tool calling to connect with real-world data, and multi-step reasoning with dynamic UI generation. We'll begin with the core architecture that powers every AI chat interface: - Set up an API route that uses `streamText`. - Implement the frontend with `useChat`. \*\*Note: Project Context\*\* We're working in `app/(5-chatbot)/` directory. Same project setup as before, but now we're building both server and client sides. ## Chatbot Architecture Overview Your chatbot will have two parts: backend + frontend. The backend connects to the LLM and provides the frontend with an API to use. The backend is required because calling the LLM apis requires secret token, authentication, rate limiting, and other functionality that runs on the server. The frontend is what the user interacts with in the browser. It's the UI. **The flow:** User → React UI (useChat) → API Route (/api/chat with streamText) → LLM → Stream chunks back → Server-Sent Events (SSE) → UI → User ## Step 1: Create Route Handler First, create the API endpoint that will handle chat requests from your frontend. \*\*Note: What are Next.js Route Handlers?\*\* [Route Handlers](https://nextjs.org/docs/app/building-your-application/routing/route-handlers) are serverless endpoints in your Next.js app. They can live anywhere in the `app/` directory (not just `/api/`), though we'll use the `/api/` convention here. No separate backend needed - perfect for AI functionality. 1. **Create the file:** `app/api/chat/route.ts` 2. **Start with this basic structure:** ```typescript title="app/api/chat/route.ts" import { streamText, convertToModelMessages, createUIMessageStreamResponse, toUIMessageStream, } from 'ai'; // Allow streaming responses up to 30 seconds export const maxDuration = 30; export async function POST(req: Request) { // TODO: Extract messages from the request body // TODO: Create a streamText call with: // - model: 'openai/gpt-5-mini' // - messages: converted using convertToModelMessages // TODO: Return the stream with createUIMessageStreamResponse + toUIMessageStream } ``` 3. **Now implement the streaming chat endpoint:** ```typescript title="app/api/chat/route.ts" {12-39} import { streamText, convertToModelMessages, createUIMessageStreamResponse, toUIMessageStream, } from "ai"; // Allow streaming responses up to 30 seconds export const maxDuration = 30; export async function POST(req: Request) { try { const { messages } = await req.json(); const result = streamText({ model: "openai/gpt-5-mini", // Fast model for real-time chat (immediate streaming, low latency) // Reasoning models ('openai/gpt-5') would add 10-15s delay - poor UX for chat messages: await convertToModelMessages(messages), }); return createUIMessageStreamResponse({ stream: toUIMessageStream({ stream: result.stream }), }); } catch (error) { console.error("Chat API error:", error); // Return a proper error response return new Response( JSON.stringify({ error: "Failed to process chat request", details: error instanceof Error ? error.message : "Unknown error", }), { status: 500, headers: { "Content-Type": "application/json" }, }, ); } } ``` Key components explained: - `streamText` - Enables real-time streaming from the AI model - `convertToModelMessages` - Converts frontend message format to AI model format - `toUIMessageStream` + `createUIMessageStreamResponse` - Format the stream for the frontend to consume ## Step 2: Implement Frontend with useChat Now let's build the UI using the `useChat` hook. Open `app/(5-chatbot)/chat/page.tsx` and replace the placeholder content. 1. **Start with the imports and basic setup:** ```typescript title="app/(5-chatbot)/chat/page.tsx" 'use client'; import { useChat } from '@ai-sdk/react'; import { useState } from 'react'; export default function Chat() { const [input, setInput] = useState(''); // TODO: Initialize useChat hook // - Extract: messages and sendMessage return (
{/* TODO: Display messages here */} {/* TODO: Add input form here */}
); } ``` 2. **Add the useChat hook and message display:** ```typescript title="app/(5-chatbot)/chat/page.tsx" {8, 12-22} "use client"; import { useChat } from "@ai-sdk/react"; import { useState } from "react"; export default function Chat() { const [input, setInput] = useState(""); const { messages, sendMessage } = useChat(); return (
{messages.map((message) => (
{message.role === "user" ? "User: " : "AI: "} {message.parts?.map( (part, i) => part.type === "text" && ( {part.text} ), )}
))} {/* TODO: Add input form here */}
); } ``` \*\*Note: Default API Endpoint\*\* The `useChat` hook automatically uses `/api/chat` as its endpoint. If you need a different endpoint or custom transport behavior, check out the [transport documentation](https://ai-sdk.dev/docs/ai-sdk-ui/transport). 3. **Add the input form:** ```typescript title="app/(5-chatbot)/chat/page.tsx" {24-45} "use client"; import { useChat } from "@ai-sdk/react"; import { useState } from "react"; export default function Chat() { const [input, setInput] = useState(""); const { messages, sendMessage } = useChat(); return (
{messages.map((message) => (
{message.role === "user" ? "User: " : "AI: "} {message.parts?.map( (part, i) => part.type === "text" && ( {part.text} ), )}
))}
{ e.preventDefault(); if (!input.trim()) return; try { await sendMessage({ text: input }); setInput(""); } catch (error) { console.error("Failed to send message:", error); // TODO: Show user-friendly error message // You could add a toast notification here } }} > setInput(e.target.value)} />
); } ``` How it works: - `useChat()` manages the entire chat state and API communication - `messages` contains the conversation history - `sendMessage()` sends user input to your API - Messages have `parts` for different content types (text, tool calls, etc.) The combination of `streamText` and `useChat` handles most of the streaming complexity for you - no manual WebSocket management or stream parsing needed. ## Step 3: Test Your Chatbot Run the development server: ```bash pnpm dev ``` Navigate to [`http://localhost:3000/chat`](http://localhost:3000/chat) Try it out - type a message and hit Enter. Watch the AI response appear in real time! ![simple chat UI. User types 'Hello!' and presses Enter. AI response 'Hello there! How can I help you today?' streams into the chat window.](https://ezs2ytwtdks5l2we.public.blob.vercel-storage.com/ai-sdk-course-basic-streaming-chat.gif) ## Experience the Limitations Before moving on, test these scenarios to understand why we need better tooling: 1. **Ask for code**: "Write a Python function to calculate fibonacci numbers" - Notice how code blocks appear as raw \`\`\` text 2. **Have a long conversation**: Keep chatting until messages go below the fold - You'll have to manually scroll to see new responses 3. **Ask for formatted content**: "Explain AI with headers and lists" - Markdown formatting shows as plain text 4. **Refresh the page**: All your conversation history disappears 5. **Try to edit a long prompt**: The single-line input is limiting These aren't bugs - they're missing features that every chat interface needs. \*\*Note: Model Choice for Streaming\*\* We use `openai/gpt-5-mini` for fast, visible streaming responses. Unlike reasoning models like `openai/o4-mini` (which think for 10-15 seconds before streaming), `gpt-5-mini` starts streaming immediately for a responsive user experience. Swap out the model in the `streamText` call to `openai/o4-mini` to see the difference. ## What you've built so far: - Two components: Backend (`streamText` API route) + Frontend (`useChat` component) - `streamText` manages server-side AI calls and streaming - `useChat` handles UI state, messages, and API calls - `toUIMessageStream` + `createUIMessageStreamResponse` connect backend to frontend - display the messages in the UI by parsing the response from the backend \*\*Note: Feeling the Pain Yet?\*\* Notice how much custom code we had to write just for basic functionality? Try having a longer conversation and watch the problems pile up: **Immediate Issues You'll Notice:** - **No markdown rendering** - If the AI sends code blocks or formatting, they show as raw text - **No auto-scrolling** - New messages appear below the viewport, you have to manually scroll - **Basic styling** - Just "User:" and "AI:" labels, no proper message bubbles - **Fixed input weirdness** - The input floats awkwardly at the bottom **Missing Features You'll Need:** - **Loading indicators** - No visual feedback while waiting for AI - **Error handling** - If the API fails, users see nothing - **Multi-line input** - Can't compose longer messages easily - **Message persistence** - Refresh = conversation gone - **Code syntax highlighting** - Code examples are unreadable You could spend weeks building all this from scratch... or there might be a better way. 🤔 \*\*Side Quest: Conversation Memory System\*\* ```typescript title="memory-service.ts" export async function registerMemory(message: string, summary: string) { // TODO: persist memory entry { message, summary, embedding } // 1. Generate embedding for the message // 2. Store in vector DB with metadata // 3. Update conversation summary } export async function retrieveMemories(query: string, limit = 5) { // TODO: retrieve relevant memories for context // 1. Generate embedding for query // 2. Search vector DB for similar memories // 3. Return ranked results } ``` ## Next Step: A Professional Solution In the next lesson, we'll discover how to transform this basic chatbot into a professional interface with a single command. Get ready to have your mind blown by AI SDK Elements! --- title: "AI Elements" description: "Transform your chatbot with professional AI components" canonical_url: "https://vercel.com/academy/ai-sdk/ai-elements" md_url: "https://vercel.com/academy/ai-sdk/ai-elements.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-21T03:14:41.448Z" content_type: "lesson" course: "ai-sdk" course_title: "Builders Guide to the AI SDK" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # AI Elements # Rebuilding the UI with AI Elements Remember all that custom UI code we just wrote? The manual state management, the scroll behavior, the message formatting? Professional chat interfaces like ChatGPT, Claude, and Cursor use sophisticated component systems to handle these complexities. **AI Elements gives you that same power - let's transform your basic chat into something production-ready.** ## Introducing AI SDK Elements The AI SDK team has built **Elements** - a comprehensive component library specifically designed for AI applications. It's built on top of shadcn/ui and provides everything you need out of the box. \*\*Note: What are AI SDK Elements?\*\* [Elements](https://ai-sdk.dev/elements/overview) is a collection of 20+ production-ready React components designed specifically for AI interfaces. These components are tightly integrated with AI SDK hooks like `useChat`, handling the unique challenges of streaming responses, tool displays, and markdown rendering that standard React components don't address. Unlike regular UI libraries, Elements understands AI-specific patterns - message parts, streaming states, tool calls, and reasoning displays - making it the perfect companion to the AI SDK. ## Installing Elements Let's transform our chatbot with a single command: ```bash pnpm dlx ai-elements@latest ``` When prompted, press **Enter** to confirm the installation path, and select **Yes** when asked about overwriting existing components. \*\*What happens during installation?\*\* Elements will: - Upgrade existing shadcn/ui components (button, input, etc.) - Add new AI-specific components to `components/ai-elements/` - Install dependencies like `use-stick-to-bottom` for auto-scrolling - Add utilities for markdown streaming and syntax highlighting ## The New Components After installation, check out what you now have in `components/ai-elements/`: - **Conversation** - Handles the entire chat container with auto-scrolling - **Message** - Properly styled message display with role-based alignment - **MessageResponse** - Markdown renderer with syntax highlighting (part of the Message component) - **PromptInput** - Smart input with auto-resize and attachment support - **Reasoning** - Displays AI thought processes (for reasoning models) - **Tool** - Displays tool usage in conversations - And 14 more specialized components! ## Step 1: Add the Elements Imports First, we need to import all the Elements components we'll be using. Add these imports at the top of your `app/(5-chatbot)/chat/page.tsx` file, right after your existing imports: ```tsx title="app/(5-chatbot)/chat/page.tsx" {7-17} // Your existing imports 'use client'; import { useState } from 'react'; import { useChat } from '@ai-sdk/react'; // Add ALL these new Elements imports import { Conversation, ConversationContent, ConversationEmptyState } from "@/components/ai-elements/conversation"; import { Message, MessageContent } from "@/components/ai-elements/message"; import { PromptInput, PromptInputTextarea, PromptInputSubmit } from "@/components/ai-elements/prompt-input"; ``` With the imports ready, we can now progressively replace each part of the UI. ## Step 2: Replace the Message Display Now let's replace how messages are displayed using Elements' `Message` component. **Update just the message rendering part (around line 12-20):** ```tsx // Replace this: {messages.map(message => (
{message.role === 'user' ? 'User: ' : 'AI: '} {message.parts?.map((part, i) => part.type === 'text' && {part.text} )}
))} // With this: {messages.map((message) => ( {message.parts?.map((part) => part.type === 'text' && part.text )} ))} ``` ![Screenshot of the chatbot with proper message bubbles](https://ezs2ytwtdks5l2we.public.blob.vercel-storage.com/ai-sdk-course-message-bubbles.png) Save and test. You'll see proper message bubbles instead of "User:" and "AI:" labels! But we still have the scrolling issues and basic input. ## Step 3: Add Smart Scrolling with Conversation Container Now let's wrap everything in the `Conversation` component which handles auto-scrolling. **Wrap your messages in the Conversation components (you already have the imports from Step 1):** ```tsx // Replace the outer div and message list with:
{messages.length === 0 ? ( ) : ( // Your existing message map code here messages.map((message) => ( {message.parts?.map((part) => part.type === 'text' && part.text )} )) )} {/* Keep your existing input form here for now */}
``` Test again with a more complex prompt - now messages stay in view and the view scrolls as they stream in! Plus you get a nice empty state. The text input is poorly formatted and needs to be updated too. ## Step 4: Upgrade the Input with PromptInput Finally, let's replace the basic input with Elements' smart input components. **Update the status handling (you already have the imports from Step 1):** ```tsx // add `status` to the useChat hook const { messages, sendMessage, status } = useChat(); // add `isLoading` based on the current status const isLoading = status === "streaming" || status === "submitted"; ``` **Replace your form with the PromptInput components:** ```tsx // Replace your entire
with:
{ event.preventDefault(); if (message.text) { sendMessage({ text: message.text }); setInput(""); } }} className="max-w-3xl mx-auto flex gap-2 items-end" > setInput(e.target.value)} placeholder="Type your message..." disabled={isLoading} rows={1} className="flex-1" />
``` ## Testing After All Four Steps Save your changes and test the chatbot: ```bash pnpm dev ``` Navigate to and try it out. After these incremental improvements, you'll notice: - ✅ **Step 1: All imports ready** - Set up for success - ✅ **Step 2: Professional message bubbles** - Much better than "User:" and "AI:" labels - ✅ **Step 3: Auto-scrolling & empty state** - Messages stay in view, clean UI when no messages - ✅ **Step 4: Smart input field** - With a proper send button and better UX - ❌ **But wait...** Ask the AI to write code and you'll see markdown symbols like \`\`\` instead of formatted code blocks! \*\*Note: Four Steps Complete\*\* We've incrementally replaced our custom UI with Elements components: 1. Added all the necessary imports upfront 2. Upgraded just the message display 3. Added smart scrolling with Conversation container 4. Upgraded the input with PromptInput The interface looks professional, but markdown isn't rendering yet. Let's fix that next! ## Step 5: Enable Markdown Rendering with MessageResponse Component The messages look better, but markdown isn't rendering. Elements includes a `MessageResponse` component that handles markdown beautifully. Let's use it for AI messages: ```tsx // Add MessageResponse to your existing message import import { Message, MessageContent, MessageResponse } from "@/components/ai-elements/message"; // Then update the message rendering part: messages.map((message) => ( {message.role === 'assistant' ? ( {message.parts ?.filter(part => part.type === 'text') .map(part => part.text) .join('')} // 👈 Wrap AI messages in MessageResponse ) : ( message.parts?.map((part) => part.type === 'text' && part.text ) )} )) ``` ### The Magic Moment ✨ Refresh your browser and ask the AI to write code again. Try: "give me a react app that uses the ai sdk to build a chat" ![Screenshot of the chatbot with properly formatted code blocks](https://ezs2ytwtdks5l2we.public.blob.vercel-storage.com/ai-sdk-streaming-formatted-chat.gif) **BOOM!** Look at the transformation: - ✅ **Syntax-highlighted code blocks** - Beautiful, readable code - ✅ **Copy and download buttons** - Professional code block features - ✅ **Proper markdown formatting** - Headers, lists, bold, italic - ✅ **Inline code styling** - `code` appears formatted - ✅ **Professional presentation** - Looks like ChatGPT or Claude With just **one component change**, you've transformed raw markdown text into beautifully formatted content! \*\*Note: Performance Note\*\* The `MessageResponse` component is optimized for streaming - it efficiently handles incremental markdown updates without re-parsing the entire content on each stream chunk. This is crucial for maintaining smooth performance during AI responses. ## The Complete Code Here's the final version with both improvements: ```tsx title="app/(5-chatbot)/chat/page.tsx" "use client"; import { useChat } from "@ai-sdk/react"; import { useState } from "react"; import { Conversation, ConversationContent, ConversationEmptyState, } from "@/components/ai-elements/conversation"; import { Message, MessageContent, MessageResponse } from "@/components/ai-elements/message"; import { PromptInput, PromptInputTextarea, PromptInputSubmit, } from "@/components/ai-elements/prompt-input"; export default function Chat() { const [input, setInput] = useState(""); const { messages, sendMessage, status } = useChat(); const isLoading = status === "streaming" || status === "submitted"; return (
{messages.length === 0 ? ( ) : ( messages.map((message) => ( {message.role === "assistant" ? ( {message.parts ?.filter((part) => part.type === "text") .map((part) => part.text) .join("")} // 👈 Wrap AI messages in MessageResponse ) : ( message.parts?.map( (part) => part.type === "text" && part.text, ) )} )) )}
{ event.preventDefault(); if (message.text) { sendMessage({ text: message.text }); setInput(""); } }} className="max-w-3xl mx-auto flex gap-2 items-end" > setInput(e.target.value)} placeholder="Type your message..." disabled={isLoading} rows={1} className="flex-1" />
); } ``` \*\*Note: How useChat Connects to Your API\*\* `useChat()` uses the AI SDK's transport layer to communicate with your backend. By default, it sends requests to `/api/chat` using `DefaultChatTransport`, which handles streaming responses, message formatting, and error handling automatically. You can customize the endpoint with the `api` option: `useChat({ api: '/api/custom' })`, or provide a custom transport implementation for advanced scenarios like authentication, request transformation, or non-standard protocols. See the [transport documentation](https://ai-sdk.dev/docs/ai-sdk-ui/transport) for details. ## The Transformation Summary Look at what we accomplished in two simple steps: **Before (Custom UI)** - 100+ lines of code - Manual scroll management - Raw markdown text - No code highlighting - Basic "User:" / "AI:" labels - Fixed input position issues **After (Elements)** - \~60 lines of clean code - Auto-scrolling built-in - Beautiful markdown rendering - Syntax-highlighted code blocks - Professional message bubbles - Smart input with send button ## What Else is in Elements? You've installed 20+ components with Elements. While we're focusing on the chat basics now, here's what else you have available: - **Tool Component** - We'll use this when we add weather checking and tool calling - **Reasoning Component** - Shows AI's thought process (for models like o1) - **Suggestion Component** - Quick reply buttons below the input - **Attachment Component** - File upload support - **Citation Component** - Source references in responses Don't worry about implementing these now - we'll explore tool usage in the upcoming lessons where you'll see these components in action. ## Why This Matters \*\*Reflection:\*\* Think about the two-step progression we just went through. How did each step improve the user experience? Why is it valuable to see the transformation happen incrementally rather than all at once? You've just experienced firsthand why component libraries exist. Elements didn't just save you time - it provided: - ✅ **Battle-tested solutions** to common problems - ✅ **Accessibility features** built-in - ✅ **Performance optimizations** you didn't have to think about - ✅ **Consistent design patterns** across your app - ✅ **Professional polish** that would take weeks to build yourself ## Next Steps Now that we have a solid foundation with Elements, we can focus on what really matters - the AI functionality. In the next lessons, we'll explore: - Adding personality with system prompts - Integrating reasoning models - File attachments and multimodal input - Tool use and function calling - Citations and source tracking All using the professional components from Elements! - [ ] AI SDK Elements installed successfully? - [ ] All Elements imports added? - [ ] Messages displaying with proper bubbles? - [ ] Auto-scrolling working? - [ ] Smart input with send button? - [ ] Markdown rendering enabled? \*\*Note: Explore More Elements Components\*\* Elements includes 20+ components beyond what we've used: - **Suggestions** - Quick prompts below the input - **Loader** - Custom loading indicators for streaming - **ChainOfThought** - Visualize reasoning steps - **Branch** - Enable conversation forking - **TypingIndicator** - Show when AI is responding Browse `components/ai-elements/` to discover more components and enhance your chat interface! --- title: "System Prompts" description: "Customize your AI chatbot's behavior and personality using system prompts. Learn to shape responses with persistent instructions." canonical_url: "https://vercel.com/academy/ai-sdk/system-prompts" md_url: "https://vercel.com/academy/ai-sdk/system-prompts.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-21T03:14:41.472Z" content_type: "lesson" course: "ai-sdk" course_title: "Builders Guide to the AI SDK" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # System Prompts # Using System Prompts to Shape AI Personality Now that we have a professional chat interface with Elements, let's give our AI some personality! System prompts are like **permanent instructions** that shape how your AI behaves throughout an entire conversation. While user messages change with each interaction, the system prompt remains constant, ensuring consistent personality and behavior. \*\*Note: Building on Elements\*\* We'll be modifying the API route while keeping our beautiful Elements UI from the previous lesson. The visual impact of different personalities will be even more striking with professional message bubbles and markdown rendering! ## What are System Prompts? System Prompts act like persistent instructions or "character notes" for an LLM. Unlike user prompts (which change each turn), a system prompt guides overall behavior of the LLM when it is generating responses. Your system prompt will: - **Defines Persona:** Sets tone (e.g., formal, casual, witty, brand voice). - **Set Constraints:** Instructs AI on boundaries (e.g., "Do not offer financial advice", "Only discuss product features"). - **Provide Core System Context:** Gives background relevant to all interactions (e.g., "You are a helpful assistant for Vercel products"). Your system prompts control how the LLM responds to *every* prompt in a conversation, separate from *what the user* asks with their prompts throughout a conversation. The system prompt is essential for branding, safety, and consistent bot behavior. **The flow:** System Prompt (persistent) + User Messages (changing) → LLM → Responses (all guided by system prompt) ## Implementation: The **`instructions`** Property Let's modify our existing API route to add personality. Open `app/api/chat/route.ts` and add an `instructions` property to your `streamText` call: ```typescript title="app/api/chat/route.ts" import { streamText, convertToModelMessages, createUIMessageStreamResponse, toUIMessageStream, } from 'ai'; export const maxDuration = 30; export async function POST(req: Request) { const { messages } = await req.json(); const result = streamText({ model: 'openai/gpt-5-mini', // TODO: Add a system prompt here to define the AI's personality // instructions: 'Your personality instructions here', messages: await convertToModelMessages(messages), }); return createUIMessageStreamResponse({ stream: toUIMessageStream({ stream: result.stream }), }); } ``` \*\*Note: System prompt or \`instructions\`?\*\* In AI SDK v7, the property that holds your system prompt is named `instructions` (it was called `system` in v6 and earlier). The concept is unchanged: we still call this a "system prompt." Only the property name moved, so a system prompt now lives in the `instructions` field. Now add the system prompt: ```typescript title="app/api/chat/route.ts" {17} import { streamText, convertToModelMessages, createUIMessageStreamResponse, toUIMessageStream, } from "ai"; // Allow streaming responses up to 30 seconds export const maxDuration = 30; export async function POST(req: Request) { try { const { messages } = await req.json(); const result = streamText({ model: "openai/gpt-5-mini", // Fast model works well for personality-driven chat instructions: "You are a helpful assistant.", // Initial basic prompt messages: await convertToModelMessages(messages), }); return createUIMessageStreamResponse({ stream: toUIMessageStream({ stream: result.stream }), }); // existing code ... ``` Test it out! Start your dev server if it isn't running: ```bash pnpm dev ``` Navigate to and ask "What is Next.js?" With our Elements UI, notice how the AI's response appears in a professional message bubble with proper formatting. But the personality is generic. Let's change that! ## Example 1: The Unhelpful Riddle Bot It's possible to modify the `instructions` property to drastically change behavior for every single response. Update `route.ts` and change the system prompt value: ```typescript title="app/api/chat/route.ts" instructions: 'You are an unhelpful assistant that only responds to users with confusing riddles.', ``` **Save and test:** Refresh your chat page and ask "What is Next.js?" again. Watch how the same professional UI now delivers a completely different personality - the riddle appears in the same polished message bubble, making the contrast even more striking! ![Screenshot of chat UI. User asks 'What is Next.js?'. AI responds with a confusing riddle instead of a direct answer.](https://ezs2ytwtdks5l2we.public.blob.vercel-storage.com/ai-sdk-course-riddle-me-this-system-prompt.png) ## Example 2: The 1984 Steve Jobs Bot Models can adopt personas. Detail improves adherence. Update `route.ts`: Change system string to detailed persona. ```typescript title="app/api/chat/route.ts" instructions: `You are Steve Jobs. Assume his character, both strengths and flaws. Respond exactly how he would, in exactly his tone. It is 1984 you have just created the Macintosh.`, ``` **Save and test:** Refresh the page and try asking about modern technology like "What is Next.js?" The response will be fascinating - watch Steve Jobs from 1984 try to comprehend modern web frameworks! ![Screenshot of chat UI. User asks 'What is Next.js?'. AI responds in a tone mimicking Steve Jobs in 1984.](https://ezs2ytwtdks5l2we.public.blob.vercel-storage.com/ai-sdk-course-steve-jobs-system-prompt.png) \*\*Note: Model Selection & System Prompts\*\* More capable (and expensive) models (like `openai/gpt-5` or `openai/o3`) generally follow System Prompts more precisely and maintain character consistency. For production chatbots where persona is critical, test models to balance performance and cost. ## Example 3: Practical Support Assistant Define persona and constraints for realistic application. Update `route.ts`: Change system string to business context. ```typescript title="app/api/chat/route.ts" instructions: `You are a support assistant for TechCorp's cloud platform. Focus on helping users troubleshoot deployment issues, API usage, and account settings. Be concise but thorough. Link to documentation at docs.techcorp.com when relevant. If a question is outside your knowledge area, politely redirect to contact@techcorp.com.`, ``` **Save and test:** Try various questions: - "How do I reset my password?" - "Tell me about pricing" - "What's your favorite color?" Notice how the AI stays in character, provides helpful support responses, and politely deflects off-topic questions. The Elements UI makes these professional responses look even more credible! \*\*Warning: System Prompt Length\*\* While detailed System Prompts improve behavior, very long prompts consume context window space, potentially affecting performance or cost. Keep prompts concise yet clear for production. ## Key Takeaways System prompts transform your chatbot from a generic assistant into a unique personality: - **The `instructions` property** in `streamText` sets persistent behavioral rules - **Personas stick** - The AI maintains character across the entire conversation - **Details matter** - More specific prompts lead to better adherence - **Elements amplifies impact** - Professional UI makes personality changes more striking \*\*Reflection:\*\* Imagine building a chatbot for a specific purpose (e.g., company support, technical documentation assistant, personal project). What system prompt would define its core personality, tone, and key constraints? Draft 2-3 sentences for system prompt. \*\*Note: 💡 Crafting Effective System Prompts\*\* Struggling to balance personality with constraints? Try asking an AI assistant: ```markdown title="Prompt: Designing Balanced System Prompts" I'm building a chatbot using Vercel AI SDK with streamText and system prompts. I need to define a system prompt that gives the AI a clear personality while maintaining appropriate boundaries. My chatbot will be used for: [describe your specific use case] Type: Customer support chatbot for a SaaS product (project management tool) Target audience: Small business owners and team leads Tone desired: Friendly but professional, helpful without being overly casual Key constraints: - Should not make promises about features or timelines - Must redirect billing/account issues to support email - Should stay on-topic (product features, usage help, troubleshooting) instructions: "You are a helpful support assistant for ProjectFlow, a project management tool. Help users with features and troubleshooting. Be friendly." 1. **Too generic:** The AI sometimes goes off-topic (e.g., answering general project management theory instead of focusing on our tool) 2. **Lacks boundaries:** When asked about pricing changes, the AI makes up information instead of redirecting 3. **Inconsistent tone:** Sometimes overly formal, sometimes too casual 4. **No examples:** The AI doesn't know what kind of help to prioritize 1. How detailed should my system prompt be? Is 2-3 sentences enough or should it be longer? 2. Should I use examples in the system prompt to show the desired behavior? 3. How do I phrase constraints so the AI gracefully deflects instead of saying "I can't help with that"? 4. What's the best way to define tone? Specific adjectives vs example phrases? 5. Should I mention what the AI *should* do or what it *shouldn't* do (or both)? I want the AI to: - Focus exclusively on our product's features and usage - Redirect billing/account questions to support@projectflow.com - Sound like a knowledgeable teammate, not a corporate robot - Provide actionable steps, not just general advice - Maintain consistent personality across the entire conversation Draft an improved system prompt (100-200 words) that addresses these problems, then explain your design choices. ``` This will help you understand strategies for crafting system prompts that balance personality with appropriate constraints! ## What's Next? Your chatbot now has personality, but it's still limited to conversation. In the next lesson, we'll give it **superpowers** by adding tool calling - letting it fetch real data, perform calculations, and interact with external APIs. Imagine your Steve Jobs bot being able to actually look up modern technology, or your support assistant actually checking account statuses! --- title: "Tool Use" description: "Enable your chatbot to interact with external APIs and functions using AI SDK Tools. Define tools, handle function calls, and return results to the LLM." canonical_url: "https://vercel.com/academy/ai-sdk/tool-use" md_url: "https://vercel.com/academy/ai-sdk/tool-use.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-21T03:14:41.496Z" content_type: "lesson" course: "ai-sdk" course_title: "Builders Guide to the AI SDK" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Tool Use # Tool Calling to Connect to External Data Sources Your chatbot has personality ([system prompts](./system-prompts)) and a beautiful UI ([Elements](./ai-elements)), but it lacks real-time knowledge. It doesn't know today's weather, can't check prices, or access current data. Tools let your AI call functions to fetch data, perform calculations, or interact with external APIs. They bridge the gap between the AI's static knowledge and the dynamic real world. \*\*Note: Building on Elements\*\* We'll add tool calling to our [Elements-powered chat interface](./ai-elements). Building on the [basic chatbot](./basic-chatbot) and [system prompts](./system-prompts) lessons, we'll extend our chat with real-world data access. The professional UI will make tool invocations visible and interactive! ## The Problem: LLM Limitations Base LLMs operate within constraints: - **Knowledge Cutoff:** Lack real-time info (weather, news, stock prices). LLMs are training on a static dataset, so typically only have data earlier than their knowledge cutoff date. - **Inability to Act:** Cannot directly interact with external systems (APIs, databases). LLMs produce text. They don't have capabilities beyond that. Asking "What's the weather in San Francisco?" fails because the model lacks live data access. The model has no idea what the current weather is in San Francisco. AI is amazing, but the model is always a snapshot of the past. Thankfully this problem can be solved with "tool calling" which gives your model the ability to run code based on your conversation context. The results of these function calls can then be fed back into your prompt context to generate a final response. ## Calling Tools with the AI SDK (Function Calling) Tools allow the model to access functions based on conversation context. They are like a hotline the LLM can pick up, call a pre-defined function, and pop the results back inline. ### Here's the Flow: 1. **User Query:** Asks a question requiring external data/action. 2. **Model Identifies Need:** Matches query to tool `description`. 3. **Model Generates Tool Call:** Outputs structured request to call specific tool with inferred parameters. 4. **SDK Executes Tool:** API route receives call, SDK invokes `execute` function. 5. **Result Returned:** `execute` function runs (e.g., calls weather API), returns data. 6. **Model Generates Response:** Tool result is automatically fed back to model for final text response. If you've used a coding environment like Cursor, you've seen this flow in action. That's how Cursor and similar tools interact with your codebase. Remember that tools grant LLMs access to real-time data and action capabilities, dramatically expanding chatbot usefulness. To see this in action you'll build a tool to check the weather. ## Step 1: Define `getWeather` Tool Create a new file `app/api/chat/tools.ts` to define our weather tool. \*\*Note: Server Actions or Route Handlers?\*\* Tool endpoints in this lesson live under `/app/api/chat` because we need a reusable HTTP surface that the `useChat` hook (and anything else) can `fetch`. The AI SDK defaults to that Route Handler path, so keep it in place for chat flows even if you reuse the same mutation logic elsewhere. When your UI is the only caller and the mutation is form-driven, Server Actions keep things ergonomic (secure secrets, automatic revalidation, no endpoint). If other clients (mobile apps, webhooks, cron jobs) hit the same logic, move it into a Route Handler or share a module between both surfaces. The Next.js docs on [Updating Data](https://nextjs.org/docs/app/getting-started/updating-data), [Route Handlers](https://nextjs.org/docs/app/getting-started/route-handlers), and the [Backend-for-Frontend guide](https://nextjs.org/docs/app/guides/backend-for-frontend) lay out the trade-offs, and the [AI SDK Next.js quickstart](https://ai-sdk.dev/docs/getting-started/nextjs-app-router) documents the default `/app/api/chat` contract. 1. **Start with the basic structure:** ```typescript title="app/api/chat/tools.ts" import { tool } from 'ai'; import { z } from 'zod'; export const getWeather = tool({ // TODO: Add a clear description for the AI to understand when to use this tool description: '', // TODO: Define the input schema using Zod // The tool needs a 'city' parameter (string) inputSchema: z.object({ // Add schema here }), // TODO: Implement the execute function // This function runs when the AI calls the tool execute: async ({ city }) => { // Implementation goes here }, }); ``` 2. **Add the description to help the AI understand when to use this tool:** ```typescript title="app/api/chat/tools.ts" {2} export const getWeather = tool({ description: `Get the current weather conditions and temperature for a specific city.`, // Still TODO: inputSchema and execute }); ``` The description is what the AI reads to decide if this tool matches the user's request. \*\*Note: Prompt Engineering for Tools\*\* The `description` field is crucial - it's how the AI understands when to use your tool. Be specific and clear: - ✅ Good: "Get current weather for a specific city. Use when users ask about weather, temperature, or conditions." - ❌ Bad: "Weather tool" The AI uses semantic matching between the user's query and your description to decide which tool to call. 3. **Define the input schema - what parameters the tool needs:** ```typescript title="app/api/chat/tools.ts" {4-6} export const getWeather = tool({ description: `Get the current weather conditions and temperature for a specific city.`, inputSchema: z.object({ city: z.string().describe('The city name for weather lookup'), }), // Still TODO: execute function }); ``` The AI will extract the city name from the user's message and pass it to your tool. \*\*Note: 💡 Need Help Designing Tool Schemas?\*\* Unsure about what parameters your tool should accept or how to structure them? Try this: ```markdown title="Prompt: Designing Effective Tool Input Schemas" I'm building a tool for my Vercel AI SDK chatbot using the `tool()` helper with Zod schemas. My tool will: [describe what your tool does] Target use cases: [describe when users would invoke this tool] Tool name: getWeather Purpose: Fetch current weather conditions and temperature for a specified location External API: Open-Meteo weather API (free, no key needed) inputSchema: z.object({ city: z.string().describe('The city name for weather lookup'), }) 1. **Parameter granularity:** Should I just accept "city" or also "country" to handle ambiguous city names (e.g., Paris, France vs Paris, Texas)? 2. **Optional parameters:** Should I add optional fields like: - `units` (celsius/fahrenheit)? - `includeHourly` (boolean for detailed forecast)? Or keep it simple with just required fields? 3. **Validation:** Should I use `.refine()` to validate city names, or trust the AI to extract valid inputs? 4. **Description quality:** My current description is "The city name for weather lookup" - is this specific enough for the AI to: - Extract the right parameter from conversational queries? - Handle variations like "What's it like in SF?" → city: "San Francisco"? 5. **Edge cases:** How should my schema handle: - Misspelled city names? - Cities with special characters (São Paulo)? - Coordinates instead of city names (some users might provide lat/lon)? - "What's the weather in San Francisco?" - "Is it raining in NYC?" - "Tell me about the temperature in Tokyo today" - "Weather forecast for London, UK" Should my schema handle all of these, or should I keep it simple and rely on the AI to normalize inputs? Recommend a schema design with rationale for each decision (parameter choices, validation, edge case handling). ``` This will help you design robust, flexible tool schemas that handle real-world usage patterns! 4. **Implement the execute function with a simple weather API:** ```typescript title="app/api/chat/tools.ts" {8-35} export const getWeather = tool({ description: `Get the current weather conditions and temperature for a specific city.`, inputSchema: z.object({ city: z.string().describe('The city name for weather lookup'), }), execute: async ({ city }) => { // For demo: use a simple city-to-coordinates mapping // In production, you'd use a geocoding API const cityCoordinates: Record = { 'san francisco': { lat: 37.7749, lon: -122.4194 }, 'new york': { lat: 40.7128, lon: -74.006 }, london: { lat: 51.5074, lon: -0.1278 }, tokyo: { lat: 35.6762, lon: 139.6503 }, paris: { lat: 48.8566, lon: 2.3522 }, }; const coords = cityCoordinates[city.toLowerCase()] || cityCoordinates['new york']; // Default fallback // Call the free Open-Meteo weather API (no key needed!) const response = await fetch( `https://api.open-meteo.com/v1/forecast?` + `latitude=${coords.lat}&longitude=${coords.lon}&` + `current=temperature_2m,weathercode&timezone=auto` ); const weatherData = await response.json(); return { city, temperature: weatherData.current.temperature_2m, weatherCode: weatherData.current.weathercode, }; }, }); ``` \*\*Note: What just happened?\*\* You built a complete tool in 4 progressive steps: 1. **Description**: Tells the AI when to use this tool 2. **Input Schema**: Defines what parameters the AI should extract 3. **Execute Function**: The actual code that runs when called 4. **Return Value**: Structured data the AI can use in its response The Open-Meteo API is free and requires no API key - perfect for demos! ## Step 2: Connect the Tool to Your API Route Now update your API route to use this tool. Modify `app/api/chat/route.ts`: ```typescript title="app/api/chat/route.ts" {7,19} import { streamText, convertToModelMessages, createUIMessageStreamResponse, toUIMessageStream, } from "ai"; import { getWeather } from "./tools"; export const maxDuration = 30; export async function POST(req: Request) { try { const { messages } = await req.json(); const result = streamText({ model: "openai/gpt-5-mini", // Fast model handles tool calling efficiently for real-time interactions instructions: "You are a helpful assistant.", messages: await convertToModelMessages(messages), tools: { getWeather }, }); return createUIMessageStreamResponse({ stream: toUIMessageStream({ stream: result.stream }), }); } catch (error) { console.error("Chat API error:", error); return new Response( JSON.stringify({ error: "Failed to process chat request", details: error instanceof Error ? error.message : "Unknown error", }), { status: 500, headers: { "Content-Type": "application/json" }, }, ); } } ``` Key changes: - Import the `getWeather` tool from `./tools` - Add `tools: { getWeather }` to register it with the AI Your chatbot now has access to the weather tool! Try asking "What's the weather in Tokyo?" - but you'll notice the response shows raw JSON data. Let's fix that next. ## Step 3: Handle Tool Calls in the UI With tools enabled, messages now have different `parts` - some are text, some are tool calls. We need to handle both types. First, update your message rendering to check the part type. Remember our current code just shows text? Let's evolve it: ```typescript title="app/(5-chatbot)/chat/page.tsx" // Current code - only handles text: {message.role === "assistant" ? ( {message.parts ?.filter((part) => part.type === "text") .map((part) => part.text) .join("")} ) : ( // user messages... )} ``` Now let's handle both text AND tool calls. We'll use a switch statement to handle different part types: ```typescript title="app/(5-chatbot)/chat/page.tsx" {3-23} // Updated code - handles multiple part types: {message.role === "assistant" ? ( message.parts?.map((part, i) => { switch (part.type) { case "text": return ( {part.text} ); case "tool-getWeather": // Tool parts are named "tool-TOOLNAME" // For now, show raw JSON to see what we're working with return (
Weather Tool Called:
Input: {JSON.stringify(part.input, null, 2)}
Output: {JSON.stringify(part.output, null, 2)}
); default: return null; } }) ) : ( // user messages stay the same... )} ``` **Test it now:** Ask "What's the weather in San Francisco?" and you'll see: - Your message appears - Raw tool call data showing the city parameter - The temperature and weather data returned - The AI's final response using that data ![Screenshot of the chat UI showing the raw tool call data](https://ezs2ytwtdks5l2we.public.blob.vercel-storage.com/ai-sdk-course-tool-call-san-francisco-raw-data-json.png) This raw view helps you understand the tool calling flow! ## Step 4: Make It Beautiful with Elements Now that you understand the raw data, let's replace that JSON dump with beautiful Elements components. First, add the Tool imports to your existing imports: ```typescript title="app/(5-chatbot)/chat/page.tsx" {2-8} import { Response } from "@/components/ai-elements/response"; import { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput, } from "@/components/ai-elements/tool"; import { PromptInput, ``` Then replace your raw JSON display with the Elements components: ```typescript title="app/(5-chatbot)/chat/page.tsx" {11-21} switch (part.type) { case "text": return ( {part.text} ); case "tool-getWeather": // Replace the raw JSON with Elements components return ( ); default: return null; } ``` **Test it:** Ask "What's the weather in San Francisco?" again. Now instead of raw JSON, you'll see: - A beautiful tool card with the tool name and status - Formatted input parameters showing the city - Nicely displayed output data with temperature and humidity ![Screenshot of the chat UI showing the beautiful tool card with the tool name and status](https://ezs2ytwtdks5l2we.public.blob.vercel-storage.com/ai-sdk-tool-call-weather-ai-elements.png) The Elements components automatically handle loading states, errors, and formatting - much better than raw JSON! ## Step 5: Test the Complete Implementation Start your dev server: ```bash pnpm dev ``` Navigate to and ask: "What's the weather in San Francisco?" You should now see: 1. **Your message** - "What's the weather in San Francisco?" 2. **Tool execution card** - Shows the weather API call with input city and output data \*\*Note: Why No Natural Language Response?\*\* Notice you only see the tool output - no AI explanation of the weather data. By default, the AI stops after executing a tool and returns the raw results. To get the AI to provide a natural language response that synthesizes the tool data (like "The weather in San Francisco is 19°C and cloudy"), you need to enable multi-step conversations. We'll cover this in the next lesson! \*\*Side Quest: Define a Complex Tool\*\* ```typescript title="app/(5-chatbot)/api/chat/tools.ts" import { z } from 'zod' export const flightBookingParameters = z.object({ trip: z.object({ origin: z.string().describe('Origin airport code (e.g., LAX, JFK)'), destination: z .string() .describe('Destination airport code (e.g., LHR, NRT)'), departureDate: z .string() .regex(/^\d{4}-\d{2}-\d{2}$/, 'YYYY-MM-DD') .describe('Departure date in YYYY-MM-DD format'), returnDate: z .string() .regex(/^\d{4}-\d{2}-\d{2}$/, 'YYYY-MM-DD') .describe('Return date in YYYY-MM-DD format (for round trips)') .optional(), }), passengers: z .array( z.object({ type: z.enum(['adult', 'child', 'infant']).describe('Passenger type'), count: z .number() .int() .min(1) .max(9) .describe('Number of this passenger type'), }), ) .min(1) .describe('Passenger list with at least one entry'), preferences: z .object({ cabinClass: z .enum(['economy', 'premium', 'business', 'first']) .describe('Preferred cabin class') .optional(), directFlightsOnly: z .boolean() .describe('Whether to only show direct flights') .optional(), }) .optional(), }); ``` \*\*Side Quest: Production-Ready Error Handling\*\* \*\*Note: 💡 Need Help with Error Handling Strategies?\*\* Unsure how to implement robust error handling in your tools? Try this: ```markdown title="Prompt: Production-Grade Tool Error Handling" I'm building production-ready tools for my Vercel AI SDK chatbot. My current tools call external APIs (weather, flight booking, etc.) and need resilient error handling. I'm working in TypeScript with async/await patterns. execute: async ({ city }) => { const coords = cityCoordinates[city.toLowerCase()]; const response = await fetch(`https://api.open-meteo.com/v1/forecast?latitude=${coords.lat}...`); const weatherData = await response.json(); return { city, temperature: weatherData.current.temperature_2m, weatherCode: weatherData.current.weathercode, }; } 1. **No timeout handling:** If the API is slow, the tool hangs indefinitely 2. **No validation:** Assumes `city` exists in `cityCoordinates` - crashes if not 3. **No response checking:** Doesn't verify `response.ok` before parsing JSON 4. **No retry logic:** Transient network failures kill the tool call 5. **No caching:** Same city requested multiple times = redundant API calls 1. **Timeout strategy:** Should I use `AbortSignal.timeout()` or a custom timeout wrapper? What's a reasonable timeout (3s? 5s? 10s)? 2. **Graceful degradation:** When an error occurs, should I: - Return a partial result with error details? - Return a user-friendly error message? - Let the AI explain the failure to the user? 3. **Retry logic:** For transient failures: - How many retries are reasonable (2? 3?)? - Should I use exponential backoff (100ms, 200ms, 400ms)? - Which HTTP status codes warrant retries (429, 500, 503)? 4. **Input validation:** Should I: - Validate city names against a whitelist before hitting the API? - Use Zod `.refine()` to check for valid inputs? - Trust the AI to provide valid inputs and just handle API errors? 5. **Caching:** Should I: - Cache successful responses for X minutes (5? 15? 30?)? - Use a simple in-memory Map or integrate Redis? - Cache based on exact input match or normalize inputs first? 6. **Error messages:** How detailed should errors be for the LLM? - Technical: "API returned 503 Service Unavailable" - User-friendly: "Weather service is temporarily down" - Actionable: "Unable to fetch weather for [city]. Try a major city name." User asks: "What's the weather in Atlantis?" (non-existent city) Current behavior: Crashes with "Cannot read property 'lat' of undefined" Desired behavior: Return friendly error the AI can explain to user Show me the error handling code that would solve this, with explanations of each technique used. ``` This will help you implement production-grade error handling patterns! ```typescript title="error-handling-tool.ts" execute: async ({ city }) => { try { const response = await fetch(url, { signal: AbortSignal.timeout(5000) // 5 second timeout }); if (!response.ok) { throw new Error(`API error: ${response.status}`); } return await response.json(); } catch (error) { console.error('Tool execution failed:', error); // Return structured error for AI to explain return { error: 'Unable to fetch weather data', city, suggestion: 'Please try another city or check back later' }; } } ``` ## Key Takeaways You've given your chatbot superpowers with tool calling: - **Tools extend AI capabilities** - Access real-time data, perform calculations, call APIs - **The `tool` helper** defines what tools can do with description, parameters, and execute - **Tool registration via `tools` property** - Makes tools available to the model - **Elements UI displays everything beautifully** - Professional presentation of both text and tool activity ## Further Reading (Optional) Strengthen your tool-calling implementation with these security-focused resources: - [LLM Function Calling Security (OpenAI Docs)](https://platform.openai.com/docs/guides/function-calling/security-considerations)\ Official guidance on hardening function calls (parameter validation, auth, rate limits). - [OWASP Top 10 for LLM Applications](https://owasp.org/www-project-top-10-for-large-language-model-applications/)\ Community-maintained list of the most critical security risks when deploying LLMs. - [Prompt Injection Payloads Encyclopedia (PIPE)](https://github.com/jthack/PIPE)\ A living catalogue of real-world prompt-injection vectors to test against. - [NVIDIA NeMo Guardrails Security Guidelines](https://docs.nvidia.com/nemo/guardrails/latest/security/guidelines.html)\ Practical design principles for safely granting LLMs access to external tools/APIs. - [Function Calling Using LLMs — Martin Fowler](https://martinfowler.com/articles/function-call-LLM.html)\ Architectural walkthrough of building a secure, extensible tool-calling agent. - [Step-by-Step Guide to Securing LLM Applications (Protect AI)](https://protectai.com/blog/step-by-step-guide-to-securing-llm-applications)\ Lifecycle-based checklist covering training, deployment and runtime hardening. ## Up Next: Multi-Step Conversations & Generative UI Your model can now call a single tool and provide responses. But what if you need multiple tools in one conversation? Or want to display rich UI components instead of just text? The next lesson explores **Multi-Step Conversations** where the AI can chain multiple tool calls together, and **Generative UI** to render beautiful interactive components directly in the chat. --- title: "Multi-Step & Generative UI" description: "Build chatbots that perform complex tasks requiring multiple tool calls. Manage conversation state and render dynamic Generative UI components based on tool results." canonical_url: "https://vercel.com/academy/ai-sdk/multi-step-and-generative-ui" md_url: "https://vercel.com/academy/ai-sdk/multi-step-and-generative-ui.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-21T03:14:41.521Z" content_type: "lesson" course: "ai-sdk" course_title: "Builders Guide to the AI SDK" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Multi-Step & Generative UI # Multi-Step Conversations & Generative UI Your chatbot can already call tools, but we can make it more powerful. Right now, when you ask for weather in multiple cities, the model makes separate calls - let's enable it to handle multiple steps intelligently. And those tool results? We can render them as custom React components instead of debugging displays. In this lesson, you'll enable **multi-step conversations** where the AI can chain multiple tool calls together, and **generative UI** where tool results render as custom React components instead of the Elements Tool components. ## What We're Building Try asking your current chatbot: "What's the weather in San Francisco and New York?" You'll get weather data, but the flow feels incomplete. We can make this much more intelligent by allowing the AI to: 1. Make multiple tool calls 2. Synthesize a natural language response 3. Display results in custom UI components instead of debug tool cards Good news: Your template already includes a polished `Weather` component ready to use! We'll integrate it to replace the tool debugging display. ## Step 1: Enable Multi-Step Conversations Right now, if you ask "What's the weather in San Francisco and New York?", the AI makes tool calls but doesn't provide a natural language summary afterward. Let's fix that! ### Why Multi-Step is Required Without multi-step, the AI must choose ONE action per request: - **Either** call tools (one or multiple) - **Or** generate a text response - **Not both!** This limitation means: - ❌ Can't call weather tool AND explain the results - ❌ Can't make sequential tool calls based on previous results - ❌ Can't provide a synthesis after gathering data Multi-step conversations solve this by allowing the AI to take multiple "steps" where each step can: 1. Call one or more tools in parallel 2. Process tool results 3. Decide to call more tools OR generate a response 4. Finally synthesize everything into natural language Learn more about [multi-step interfaces](https://ai-sdk.dev/docs/advanced/multistep-interfaces) and [isStepCount](https://ai-sdk.dev/docs/reference/ai-sdk-core/is-step-count) in the documentation. The key is adding `stopWhen` configuration to your API route: ```typescript title="app/api/chat/route.ts" {1,12} import { streamText, convertToModelMessages, isStepCount } from 'ai'; import { getWeather } from './tools'; // In your POST function: const result = streamText({ model: "openai/gpt-5-mini", // Fast model for multi-step workflows with tool chaining instructions: `You are a helpful assistant. When using tools, only mention capabilities you actually have. The weather tool provides current temperature, conditions, and humidity only - no forecasts.`, messages: await convertToModelMessages(messages), tools: { getWeather }, stopWhen: isStepCount(5), // ADD THIS: Enables up to 5 steps }); ``` \*\*Note: Understanding isStepCount\*\* The `isStepCount(5)` allows up to 5 "steps" in the conversation. Here's what might happen: **Example flow for "Weather in SF and NYC?":** - **Step 1**: AI calls `getWeather("San Francisco")` and `getWeather("New York")` in parallel - **Step 2**: AI receives both results and generates text response comparing them **Example flow for complex query:** - **Step 1**: AI calls first tool - **Step 2**: Based on results, AI calls another tool - **Step 3**: AI processes all data - **Step 4**: AI generates final response - **Step 5**: (Buffer for edge cases) Use `isStepCount(2)` for simple tool + response, `isStepCount(5)` for most cases, or `isStepCount(10)` for complex multi-tool scenarios. Each step uses tokens, so balance capability with cost. ### Testing Multi-Step Behavior 1. **Save your changes** to `app/api/chat/route.ts` 2. **Restart your dev server** if needed: `pnpm dev` 3. **Navigate to** `http://localhost:3000/chat` 4. **Test these queries** to see multi-step in action: **Single city (baseline):** "What's the weather in Tokyo?" - Expected: One tool call, then a response **Multiple cities:** "What's the weather in San Francisco and New York?" - Expected: Two tool calls, then a synthesis comparing both **Complex query:** "Compare the weather in London, Paris, and Berlin" - Expected: Three tool calls, then a comprehensive comparison ![Screenshot of the chat UI showing the two tool calls and the natural language summary](https://ezs2ytwtdks5l2we.public.blob.vercel-storage.com/ai-sdk-course-multistep-tool-call-enabled.png) You should now see: 1. Tool calls for each city (shown as Tool components) 2. A natural language summary that synthesizes all the data \*\*Note: Learn More About Multi-Step Interfaces\*\* For detailed information about multi-step interfaces and the `stopWhen` configuration, see the [Multi-step Interfaces](https://ai-sdk.dev/docs/advanced/multistep-interfaces) documentation. \*\*Note: How Multi-Step Works\*\* When the AI makes a tool call, the result automatically feeds back into the conversation context. The model then decides whether to: - Make another tool call - Provide a text response - Both ![Screenshot showing tool error handling: The tool-getWeather component displays 'fetch failed' error, but the AI provides a helpful recovery message offering alternatives](https://ezs2ytwtdks5l2we.public.blob.vercel-storage.com/ai-sdk-course-tool-call-failure.png) This even works when tools fail! If a tool returns an error, the AI can acknowledge the failure and offer alternatives or retry suggestions. ## Step 2: Build Generative UI with Weather Components Right now, tool results display using the Elements `Tool` component - which works great for debugging but isn't very user-friendly. **Generative UI** means rendering custom React components based on tool data. Instead of showing tool execution details, let's create visual weather cards that users actually want to see. \*\*Note: Generative UI Documentation\*\* Learn more about Generative UI concepts and patterns in the [Generative User Interfaces](https://ai-sdk.dev/docs/ai-sdk-ui/generative-user-interfaces) guide. ### Understanding the Weather Component Your template includes `app/(5-chatbot)/chat/weather.tsx` - a pre-built Weather component. Let's understand what it provides: ```typescript title="app/(5-chatbot)/chat/weather.tsx" preview import { Cloud, Sun, CloudRain, CloudSnow, CloudFog, CloudLightning, } from "lucide-react"; export interface WeatherData { city: string; temperature: number; weatherCode: number; humidity: number; } function getWeatherIcon(weatherCode: number) { if (weatherCode === 0) return ; if (weatherCode === 1 || weatherCode === 2) return ; if (weatherCode === 3) return ; if (weatherCode >= 51 && weatherCode <= 67) return ; if (weatherCode >= 71 && weatherCode <= 77) return ; if (weatherCode >= 80 && weatherCode <= 99) return ; return ; } function getWeatherCondition(weatherCode: number): string { if (weatherCode === 0) return 'Clear sky'; if (weatherCode === 1) return 'Mainly clear'; if (weatherCode === 2) return 'Partly cloudy'; if (weatherCode === 3) return 'Overcast'; // Add more conditions as needed return 'Unknown'; } export default function Weather({ weatherData }: { weatherData: WeatherData }) { return (

{weatherData.city}

{weatherData.temperature}°C

{getWeatherCondition(weatherData.weatherCode)}

); } ``` This component: - Exports `WeatherData` interface with city, temperature, weatherCode, and humidity that is used in the component props - Maps weather codes to appropriate icons (sun, clouds, rain, snow, etc.) - Renders a gradient card with the weather information - Has a fallback to default San Francisco weather if no data is provided ### Update Your Chat Page Now let's integrate the Weather component. We need to: 1. Import the Weather component 2. Conditionally render it for successful tool results 3. Keep the Tool component as fallback for loading/error states **TODO: Before looking at the solution below, try to:** 1. Add `import Weather from "./weather";` after your other imports (around line 24) 2. Find the `case "tool-getWeather":` section in your switch statement 3. Add a conditional check: if `part.state === "output-available" && part.output` - Render `` - Otherwise, keep the existing Tool component 4. Make sure to keep the same key prop pattern \*\*💡 Hints if you're stuck\*\* - The Weather component expects a prop called `weatherData` - Check `part.state === "output-available"` to know when the tool succeeded - You'll need both the Weather import AND keep the Tool imports for fallback - The conditional goes inside the case statement, not around it **Solution:** ```typescript title="app/(5-chatbot)/chat/page.tsx" {24, 56-63} "use client"; import { useState } from "react"; import { useChat } from "@ai-sdk/react"; import { Conversation, ConversationContent, ConversationEmptyState, } from "@/components/ai-elements/conversation"; import { Message, MessageContent } from "@/components/ai-elements/message"; import { Response } from "@/components/ai-elements/response"; import { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput, } from "@/components/ai-elements/tool"; import { PromptInput, PromptInputTextarea, PromptInputSubmit, } from "@/components/ai-elements/prompt-input"; import Weather from "./weather"; export default function ChatPage() { const [input, setInput] = useState(""); const { messages, sendMessage, status } = useChat(); const isLoading = status === "streaming" || status === "submitted"; return (
{messages.length === 0 ? ( ) : ( messages.map((message) => ( {message.role === "assistant" ? message.parts?.map((part, i) => { switch (part.type) { case "text": return ( {part.text} ); case "tool-getWeather": // Show Weather component for completed tool calls if (part.state === "output-available" && part.output) { return ( ); } // Show tool UI for other states (loading, error) return ( ); default: return null; } }) : message.parts?.map( (part) => part.type === "text" && part.text )} )) )}
{ event.preventDefault(); if (message.text) { sendMessage({ text: message.text }); setInput(""); } }} className="max-w-3xl mx-auto flex gap-2 items-end" > setInput(e.target.value)} placeholder="Type your message..." disabled={isLoading} rows={1} className="flex-1" />
); } ``` ### Implementation Guide The key changes you made: 1. **Line 24**: Import the `Weather` component from `./weather` 2. **Lines 56-63**: Modified the `tool-getWeather` case to: - Check if `part.state === "output-available"` (tool completed successfully) - If yes → Render the custom `Weather` component with the data - If no → Keep showing the `Tool` component for loading/error states This conditional rendering pattern lets you show polished UI for success while maintaining debugging visibility for errors. ### Test Your Implementation **Try it:** Ask "What's the weather in Tokyo?" and you should see a styled weather card instead of the tool display! ![Screenshot of chat UI. User asks for weather. A styled 'Weather' card component appears, visually displaying temperature, city, and condition. Final text answer follows.](https://ezs2ytwtdks5l2we.public.blob.vercel-storage.com/ai-sdk-course-ui-tokyo-weather.png) \*\*Note: Preventing AI Overpromising\*\* Notice in the screenshot the AI might offer "more details or a forecast"? Our system prompt in Step 1 helps prevent this by explicitly stating what the tool provides. If you still see overpromising, you can: - Make the tool description more explicit: `description: "Returns ONLY current temperature, weather code, and humidity - no forecasts available"` - Add validation in your tool's execute function to return clear capability messages - Implement the additional features the AI keeps promising! Perfect! Now you have polished weather cards that display instead of tool debugging info. ## Key Takeaways You've built a sophisticated chatbot with multi-step tool use and custom UI components: - **Multi-Step Conversations:** Use `stopWhen: isStepCount(5)` server-side to enable the AI to make multiple tool calls and synthesize results. - **Generative UI:** Render custom React components based on tool results (`part.state === 'output-available'`) instead of generic tool displays. - **Message Parts:** The AI SDK uses a `message.parts` array structure with typed tool parts like `tool-getWeather`. - **Conditional Rendering:** Show custom components for successful results, fallback to tool UI for loading/error states. \*\*Side Quest: Dynamic Component Mapper\*\* ```typescript title="lib/component-registry.ts" import { ComponentType } from 'react'; import Weather from '@/app/(5-chatbot)/chat/weather'; type ComponentMap = { 'tool-getWeather': typeof Weather; // TODO: Add more tool-to-component mappings }; export function getComponentForTool( toolType: T ): ComponentMap[T] | null { const registry: ComponentMap = { 'tool-getWeather': Weather, // TODO: Register your custom components here }; return registry[toolType] || null; } ``` ## Finally: Course Wrap-up & Your AI Future You've built a sophisticated chatbot with multi-step tool use and generative UI! It's time to wrap up. This final lesson provides resources, next steps, and guidance for continuing your AI development journey with the AI SDK and beyond. --- title: "Conclusion" description: "You've completed the Vercel AI SDK course! Review key learnings (LLMs, prompting, SDK features) and find resources for continued exploration in AI engineering." canonical_url: "https://vercel.com/academy/ai-sdk/conclusion" md_url: "https://vercel.com/academy/ai-sdk/conclusion.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-21T03:14:41.543Z" content_type: "lesson" course: "ai-sdk" course_title: "Builders Guide to the AI SDK" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Conclusion ## You Built Real Features Look at what you actually built: **Foundations:** - ✅ Data extraction script comparing text vs structured output modes - ✅ Model comparison tool understanding speed vs quality tradeoffs **Invisible AI Features:** - ✅ Text classifier that categorizes support tickets by type and urgency - ✅ Summarization that condenses conversations into actionable insights - ✅ Data extractor that parses natural language into structured appointments - ✅ Professional UI components generated with v0 in seconds **Full-Stack Chatbot:** - ✅ Streaming chat interface with `useChat` and `streamText` - ✅ Professional UI upgrade with AI Elements components - ✅ System prompts giving your AI consistent personality - ✅ Weather tool integration showing real-time data fetching - ✅ Multi-step conversations that chain tool calls and synthesis - ✅ Generative UI rendering custom React components from tool results This isn't toy code. These are production patterns used by real companies. ## The Patterns That Matter You learned the critical patterns that power most AI applications: 1. **Structured Extraction** - Turn messy text into clean JSON with `generateText` + `Output.object()` and Zod schemas 2. **Streaming Interfaces** - Keep users engaged while AI thinks with `streamText` and `useChat` 3. **Tool Orchestration** - Let AI call functions and APIs to extend beyond text generation 4. **Component Systems** - Professional UI with AI Elements instead of building from scratch 5. **Multi-Step Workflows** - Chain multiple tool calls with natural language synthesis These patterns are the foundation. Most AI features are variations or combinations of them. ## Your Next Move You have working code for every pattern. Pick ONE and ship it this week: **Quick Wins (1-2 hours):** - Add the classification script to your support ticket workflow - Drop the summarization Server Action into an existing Next.js app - Use the extraction pattern for any form with natural language input **Medium Projects (1-2 days):** - Build a support bot using your chatbot code + custom tools - Create a documentation assistant with system prompts for your product - Add AI Elements to upgrade any existing chat interface **Ambitious Goals (1 week):** - Multi-step workflow automation with conditional tool chains - RAG system combining extraction + search + synthesis - Custom generative UI components for domain-specific displays The gap between your code and production is just deployment. You already have the patterns. ## Key Concepts to Remember - **`Output.text()` vs `Output.object()`** - Unstructured vs structured output modes - **`useChat` + `streamText`** - The streaming chat duo - **`isStepCount()`** - Enable multi-step conversations - **AI Elements** - Don't build UI from scratch - **System prompts** - Control behavior and personality - **Tool schemas with Zod** - Type-safe tool definitions ## When You Get Stuck - **[AI SDK Docs](https://ai-sdk.dev/docs)** - Your primary reference - **[AI Elements](https://ai-sdk.dev/elements/overview)** - Component library documentation - **[Vercel AI Chatbot](https://github.com/vercel/ai-chatbot)** - Full production example with auth, persistence, and more - **[GitHub Discussions](https://github.com/vercel/ai/discussions)** - Ask questions, share what you build ## What You Learned That Others Miss Most AI tutorials show you how to call an API. This course taught you: - **Why structured output (`Output.object()`) beats raw text** for real features - **How schema evolution works** - start simple, add `.describe()`, refine iteratively - **When to use Server Actions vs API routes** for AI calls - **Why you experience the pain first** - custom UI before Elements - **How multi-step changes everything** - tool calls AND synthesis - **That debugging is part of the process** - token counting, error handling, schema validation ## One Last Thing The best AI features are invisible. Users shouldn't marvel at the AI - they should marvel at how much easier their work became. Focus on removing friction, not showcasing technology. \*\*Reflection:\*\* What's the first AI feature you'll ship this week? How will you measure success? --- title: "Build Visual Workflow Plugins on Vercel" description: "Deploy a visual workflow builder on Vercel and extend it with plugins for the APIs you actually use. Learn Vercel Workflow fundamentals along the way." canonical_url: "https://vercel.com/academy/visual-workflow-builder-on-vercel" md_url: "https://vercel.com/academy/visual-workflow-builder-on-vercel.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-09-22T04:50:21.534Z" content_type: "course" lessons: 6 estimated_time: lesson_urls: - "https://vercel.com/academy/visual-workflow-builder-on-vercel/hello-workflow.md" - "https://vercel.com/academy/visual-workflow-builder-on-vercel/webhook-workflow.md" - "https://vercel.com/academy/visual-workflow-builder-on-vercel/first-plugin.md" - "https://vercel.com/academy/visual-workflow-builder-on-vercel/resend-plugin.md" - "https://vercel.com/academy/visual-workflow-builder-on-vercel/error-handling.md" - "https://vercel.com/academy/visual-workflow-builder-on-vercel/build-your-plugin.md" --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Build Visual Workflow Plugins on Vercel Reliable background work means duct-taping queues, retries, state management, and tracing together — just to send an email without losing it. Vercel Workflow replaces the duct tape. Tag a function with `"use workflow"`, tag async calls with `"use step"`, and you get durability, retries, and observability built in. No infrastructure to manage. The visual workflow builder puts this power in a drag-and-drop canvas. Connect triggers to actions, deploy, and run durable workflows without writing boilerplate. But the real value comes when you extend it — adding plugins for Slack, Stripe, Resend, your internal services, whatever your business needs. This course teaches you how to extend the visual workflow builder with your own plugins. You'll deploy the builder, learn how it works, then create integrations for the APIs you actually use. Along the way, you'll pick up the Workflow fundamentals (steps, retries, error handling) that make your plugins production-ready. ## What You'll Build - A webhook-triggered workflow that processes external events - A custom "Shout" plugin (learning the folder pattern) - A Resend email plugin with secure credential handling - Your own plugin for Slack, Stripe, or another API you use ## Before You Start **Skills:** Comfortable with Next.js and TypeScript. An AI coding assistant helps but isn't required. **Accounts:** - [Vercel account](https://vercel.com/signup) (free tier works) - [Resend account](https://resend.com/signup) for the email plugin (free tier: 100 emails/day) **Optional:** API credentials for whatever service you want to integrate in lesson 6 (Slack, Stripe, Twilio, etc.) \*\*Note: Want to skip ahead?\*\* The [workflow-builder-template](https://github.com/vercel-labs/workflow-builder-template) has a richer set of example plugins already built (for example Resend, Slack, Linear, Firecrawl, and AI Gateway). Deploy it and go. This course uses a stripped-down starter so you learn by extending. ## Six Lessons | # | What You Do | What You Learn | Time | | - | ---------------------------------------------------------- | -------------------------------------------------------------- | -------- | | 1 | [Deploy the builder, run Hello Workflow](./hello-workflow) | Workflows execute steps durably in the background. | \~15 min | | 2 | [Build a webhook workflow](./webhook-workflow) | Workflows can pause and wait for external events. | \~20 min | | 3 | [Build your first plugin](./first-plugin) | The plugin folder pattern with zero API complexity. | \~20 min | | 4 | [Build an email plugin](./resend-plugin) | Apply the pattern to a real API with credentials. | \~25 min | | 5 | [Break it, fix it](./error-handling) | `RetryableError` retries. `FatalError` stops. You control it. | \~25 min | | 6 | [Build your own plugin](./build-your-plugin) | Wire up Slack, Stripe, or your internal API. Prove you get it. | \~45 min | **Total: \~2.5 hours** By the end, you'll have built three plugins: a toy one to learn the pattern, a real one that sends email, and your own for an API you actually use. --- title: "Hello Workflow" description: "Learn Vercel Workflow by deploying a visual workflow builder. Run your first durable workflow and see how APIs return instantly while background work continues." canonical_url: "https://vercel.com/academy/visual-workflow-builder-on-vercel/hello-workflow" md_url: "https://vercel.com/academy/visual-workflow-builder-on-vercel/hello-workflow.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-05T23:49:21.758Z" content_type: "lesson" course: "visual-workflow-builder-on-vercel" course_title: "Build Visual Workflow Plugins on Vercel" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Hello Workflow # Deploy and Run Your First Workflow You can read docs about durable execution all day, but it won't click until you see it happen. This lesson gets you to that moment in 15 minutes. ## Outcome Deploy the workflow builder, run Hello Workflow, and see the API return instantly while work continues in the background. ## Fast Track 1. Click Deploy to provision Neon + create your repo 2. Clone, link, pull env, run `pnpm dev` 3. Click Run on Hello Workflow, check Network tab for instant response ## Deploy the Visual Workflow Builder One click provisions the starter app, Neon database, environment variables, and your own repo. [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?demo-description=Visual%20Workflow%20Builder%20Starter\&demo-title=Visual%20Workflow%20Builder%20on%20Vercel\&project-name=visual-workflow-builder-on-vercel\&repository-name=workflow-builder-starter\&repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fworkflow-builder-starter\&products=%5B%7B%22type%22%3A%22integration%22%2C%22protocol%22%3A%22storage%22%2C%22productSlug%22%3A%22neon%22%2C%22integrationSlug%22%3A%22neon%22%7D%5D\&skippable-integrations=0) After deploy completes, you'll have: - A live workflow builder at your Vercel URL - A GitHub repo with the starter source - A Neon Postgres database connected and ready \*\*Note: Neon\*\* [Neon](https://neon.tech/) is serverless Postgres. The deploy button provisions a database and wires up `DATABASE_URL` automatically. You won't need to touch database config in this course. ## Set Up Local Development Clone your repo so you can build plugins locally. **1. Install Vercel CLI** (if you don't have it): ```bash npm i -g vercel ``` **2. Clone your repo:** ```bash git clone https://github.com/YOUR_USERNAME/workflow-builder-starter cd workflow-builder-starter pnpm install ``` **3. Link to your Vercel project:** ```bash vercel link ``` Select your project when prompted. **4. Pull environment variables:** ```bash vercel env pull .env.local ``` This pulls `DATABASE_URL` and any other project env vars from Vercel into `.env.local` (the standard Next.js local env file). \*\*Warning: Common Mistake: Database Connection Errors\*\* If you see `ECONNREFUSED` or connection errors, verify `.env.local` contains `DATABASE_URL` — open it and check. The value should start with `postgres://` or `postgresql://`. **5. Push database schema:** The starter uses [Drizzle ORM](https://orm.drizzle.team/) for database access. This command creates the tables your app needs: ```bash pnpm db:push ``` **6. Start the dev server:** ```bash pnpm dev ``` Open . ## Hands-on Exercise 1. You'll see the seeded **Hello Workflow** on the canvas 2. Click **Run** in the toolbar 3. Open DevTools Network tab \*\*Reflection:\*\* Before you click Run: How long do you expect the API response to take? Will the workflow be finished when the response arrives, or still running? What HTTP status code do you expect? \*\*Note: React Flow\*\* The canvas is built with [React Flow](https://reactflow.dev/). You won't need to modify it for this course — just know that nodes and edges you see become the workflow definition that gets executed. ## Try It Open Network tab and run the workflow. You should see: ``` POST /api/workflow/abc123/execute 200 142ms ``` Response: ```json { "executionId": "exec_xyz", "status": "running" } ``` Terminal shows the workflow completing *after* the response: ``` [Workflow Log] 👋 Hello from Vercel Workflow! ``` The API returned in \~100-200ms. The workflow finished a moment later. That's the pattern. \*\*Note: Mental Model: Keep Your API Fast\*\* The API returned instantly, but the workflow is still running. That's the core pattern — routes return immediately, heavy work runs via `"use workflow"` in the background. ## Solution The key insight is **fire-and-forget**: calling `start()` schedules durable execution without blocking. Your route handler doesn't `await` the workflow — it kicks it off and moves on. This is a deliberate tradeoff. Blocking until completion would tie up your function for the entire workflow duration — seconds, minutes, even hours for complex flows. Instead, `start()` returns a handle instantly, and the workflow engine takes over. You get sub-200ms API responses regardless of how long the actual work takes. The consequence: your API can't return the workflow's result directly. You'll need to poll, use webhooks, or check execution status later. That's the deal — fast APIs in exchange for async result handling. ## What's Happening The API doesn't wait for the workflow to finish. It starts the workflow and returns immediately. ``` POST /api/workflow/{id}/execute ├── Create execution record ├── start(executeWorkflow, [...]) ← kicks off background work └── Return { executionId, status: "running" } ← instant Background (continues after response): ├── Run each step └── Update execution record when done ``` The `start()` function from `@vercel/workflow` schedules durable execution without blocking. The workflow runs with retries, step isolation, and logging built in. \*\*Note: Vercel Workflow\*\* [Vercel Workflow](https://vercel.com/docs/workflow) is the durable execution engine. Tag a function with `"use workflow"`, tag async calls with `"use step"`, and you get retries, observability, and crash recovery. Steps run on [Vercel Functions](https://vercel.com/docs/functions) with [Fluid Compute](https://vercel.com/docs/fluid-compute) for efficient execution. See the [Workflows and Steps](https://workflow-sdk.dev/docs/foundations/workflows-and-steps) guide for how these directives work under the hood. ```typescript title="app/api/workflow/[workflowId]/execute/route.ts" // Don't await - just start it executeWorkflowBackground( execution.id, workflowId, workflow.nodes, workflow.edges, input ); // Return immediately return NextResponse.json({ executionId: execution.id, status: "running", }); ``` ```yaml quiz: question: "Why does the API return { status: 'running' } before the workflow finishes?" choices: - id: "async-await" text: "The code forgot to await the workflow" - id: "background" text: "Workflows run in the background so APIs stay fast" - id: "timeout" text: "The workflow timed out" - id: "error" text: "There was an error starting the workflow" correctAnswerId: "background" feedback: "{\n correct: \"Exactly. The API returns instantly while the workflow continues in the background. That's the core pattern — routes stay fast, heavy work runs durably via \\\"use workflow\\\".\",\n incorrect: \"This is intentional, not an error. The whole point is that APIs return instantly while workflows run in the background.\"\n }" ``` ## Commit ```bash git add -A git commit -m "chore: configure local dev environment for workflow builder" ``` ## Done - [ ] Deploy button created your repo and provisioned Neon - [ ] Cloned and running locally - [ ] Ran a workflow - [ ] API returned in under 200ms - [ ] Saw "👋 Hello from Vercel Workflow!" in server logs - [ ] Committed local setup ## What's Next You've seen workflows run in the background. But what if a workflow needs to wait for something external — a webhook, a user action, a payment confirmation? Lesson 2 shows how workflows pause and resume. --- title: "Webhook Workflow" description: "Build a webhook-triggered workflow that accepts HTTP POST requests and processes them durably. Your first step toward handling external events." canonical_url: "https://vercel.com/academy/visual-workflow-builder-on-vercel/webhook-workflow" md_url: "https://vercel.com/academy/visual-workflow-builder-on-vercel/webhook-workflow.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-05T23:49:21.781Z" content_type: "lesson" course: "visual-workflow-builder-on-vercel" course_title: "Build Visual Workflow Plugins on Vercel" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Webhook Workflow # Build a Webhook-Triggered Workflow In Lesson 1, you clicked "Run" to start a workflow. That's fine for testing, but real workflows start from external events — Stripe fires a payment webhook, GitHub pushes a commit event, a form submits data. The workflow needs to wake up when those events arrive. Webhook triggers solve this. The workflow gets a URL. POST to that URL, and the workflow runs with whatever data you sent. The API returns instantly (same pattern as [Hello Workflow](/visual-workflow-builder-on-vercel/hello-workflow)), and the workflow processes the event durably in the background. \*\*Note: Mental Model: Pause and Continue\*\* The workflow has a URL. It waits for a POST. When the POST arrives, execution continues exactly where it left off. No polling, no state management — the workflow literally suspends and resumes. This is the same pattern as [`createWebhook()`](https://workflow-sdk.dev/docs/api-reference/workflow/create-webhook) in the SDK. ## Outcome You'll build a webhook-triggered workflow, POST JSON to it with curl, and watch the workflow process that data through multiple steps. ## Fast Track 1. Create a new workflow with Webhook trigger 2. Add an HTTP Request action that echoes the data 3. Copy the webhook URL and POST to it with curl ## Hands-on Exercise ### 1. Create a Webhook-Triggered Workflow 1. Click **New Workflow** (or modify your existing one) 2. Click the **Trigger** node to open the properties panel 3. Change **Trigger Type** from "Manual" to **Webhook** 4. Change the **Label** from "Manual Trigger" to **Webhook Trigger** 5. You'll see a **Webhook URL** appear — copy it (or wait until after you save) The URL looks like: ``` http://localhost:3000/api/workflows/abc123/webhook ``` ### 2. Add an HTTP Request Action We'll use [JSONPlaceholder](https://jsonplaceholder.typicode.com/) to echo back our data. This proves the webhook payload flows through the workflow. 1. Click the **+** button after the trigger 2. Select **HTTP Request** from the action grid 3. Configure it: - **URL:** `https://jsonplaceholder.typicode.com/posts` - **HTTP Method:** `POST` - **Body:** ```json { "title": "{{@trigger-1:Webhook Trigger.title}}", "body": "{{@trigger-1:Webhook Trigger.body}}", "userId": 1 } ``` \*\*Warning: Template Syntax Must Match\*\* The syntax `{{@trigger-1:Webhook Trigger.title}}` has three parts: - **`trigger-1`** — the node ID (visible in the properties panel) - **`Webhook Trigger`** — the node label (must match exactly) - **`.title`** — the property from the trigger payload **Both the node ID and label must match.** If you skipped step 4 (renaming the label), your template won't resolve. Go back and change the label to "Webhook Trigger" to match the template. ### 3. Save the Workflow Click **Save** in the toolbar. The webhook URL is now active. \*\*Note: Webhook URL Only Works After Save\*\* The URL includes the workflow ID, which is assigned when you save. No save = no URL. ### 4. Set Up Mock Data for Testing Before we use curl, let's set up mock data so you can also test from the UI: 1. Click the **Trigger** node 2. Find **Mock Request (Optional)** 3. Enter: ```json { "title": "Test from UI", "body": "This is mock data for testing" } ``` Now you can click **Run** in the toolbar to test with mock data, or POST real data via curl. ## Try It Open a terminal and POST to your webhook URL: ```bash curl -X POST http://localhost:3000/api/workflows/YOUR_WORKFLOW_ID/webhook \ -H "Content-Type: application/json" \ -d '{"title": "Hello from curl", "body": "Webhook triggered!"}' ``` You should see: ```json {"executionId":"exec_xyz","status":"running"} ``` The response came back instantly — the workflow is running in the background. Check your terminal (where `pnpm dev` is running): ``` [Webhook] Starting execution: exec_xyz [Webhook] Calling executeWorkflow with: { nodeCount: 2, edgeCount: 1, ... } [Workflow Executor] Starting workflow execution [Workflow Executor] Executing trigger node [Workflow Executor] Executing action node: HTTP Request [Workflow Executor] Workflow execution completed: { success: true, ... } ``` Check the **Runs** panel in the UI — you'll see the execution with: - Trigger node showing the data you POSTed - HTTP Request node showing the response from JSONPlaceholder \*\*Reflection:\*\* If you POST { 'title': 'My Title' } (without a 'body' field), what will the HTTP Request send to JSONPlaceholder? What will the template {{@trigger-1:Webhook Trigger.body}} resolve to? ## Solution The webhook flow works like this: 1. **External system POSTs** to your workflow's webhook URL with JSON data 2. **API records the execution** and returns immediately with `{ executionId, status: "running" }` 3. **Workflow runs in background** with the POST body available as trigger output 4. **Template variables** like `{{@trigger-1:Webhook Trigger.fieldName}}` pull data from the payload **When to use webhooks vs manual triggers:** | Use Case | Trigger Type | | ------------------------------------------ | ----------------------------------------- | | Third-party events (Stripe, GitHub, Slack) | Webhook | | Scheduled jobs (via external cron) | Webhook | | User-initiated actions in your app | Webhook (POST from your frontend/backend) | | Testing and development | Manual (with mock data) | | One-off administrative tasks | Manual | Webhooks are the production pattern. Manual triggers exist for testing workflows before wiring them to real event sources. ## What's Happening The webhook endpoint does three things: 1. **Validates** — checks the workflow exists and is configured for webhooks 2. **Records** — creates an execution record with the incoming payload 3. **Starts** — calls `start(executeWorkflow, [...])` and returns immediately ```mermaid height=1200 flowchart TD A[curl POST] --> B["/api/workflows/[id]/webhook"] B --> C{Validate} C -->|workflow exists?
trigger = webhook?| D[Record execution] D -->|insert row
status: running| E{Fork} E --> F[Return 200 immediately] E --> G["start() background execution"] F --> H["{ executionId }"] ``` ```typescript title="app/api/workflows/[workflowId]/webhook/route.ts" // Parse the incoming POST body const body = await request.json().catch(() => ({})); // Create execution record with the webhook payload const [execution] = await db .insert(workflowExecutions) .values({ workflowId, userId: workflow.userId, status: "running", input: body, // ← Your curl data lands here }) .returning(); // Start workflow in background (don't await) executeWorkflowBackground( execution.id, workflowId, workflow.nodes, workflow.edges, body // ← And gets passed to the workflow ); // Return immediately return NextResponse.json({ executionId: execution.id, status: "running", }); ``` The workflow receives the POST body as `triggerInput`. Template variables like `{{@trigger-1:Webhook Trigger.title}}` pull from this data. ```yaml quiz: question: "You POST { 'user': 'alice', 'action': 'signup' } to the webhook. In your HTTP Request body, how do you access the 'action' field?" choices: - id: "a" text: "{{action}}" - id: "b" text: "{{@trigger-1:Webhook Trigger.action}}" - id: "c" text: "{{webhook.action}}" - id: "d" text: "{{input.action}}" correctAnswerId: "b" feedback: "{\n correct: \"Right. The trigger node's output is accessed via {{@trigger-1:Webhook Trigger.fieldName}}. The @trigger is the node ID, Trigger is the label, and .action accesses the field.\",\n incorrect: \"Template variables follow the pattern {{@nodeId:Label.field}}. For the trigger node, that's {{@trigger-1:Webhook Trigger.action}}.\"\n }" ``` \*\*Warning: Production: Verify Webhook Signatures\*\* This starter skips signature verification for simplicity. In production, always verify webhook signatures to prevent replay attacks and spoofed requests. Stripe, GitHub, and most providers include a signature header — see [Stripe's webhook signature docs](https://docs.stripe.com/webhooks#verify-official-libraries) for a reference implementation. ```typescript title="Example: Stripe webhook signature verification" import Stripe from 'stripe'; const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); export async function POST(request: Request) { // Read raw body for signature verification const body = await request.text(); const sig = request.headers.get('stripe-signature')!; try { // Verify the webhook came from Stripe const event = stripe.webhooks.constructEvent( body, sig, process.env.STRIPE_WEBHOOK_SECRET! ); // Safe to process - signature verified // Pass event.data.object to your workflow... } catch (err) { // Invalid signature - reject the request return new Response('Invalid signature', { status: 400 }); } } ``` ## Debugging: "This workflow is not configured for webhook triggers" If you see this error when POSTing: ```json {"error":"This workflow is not configured for webhook triggers"} ``` The trigger type is still set to "Manual". Open the workflow, click the Trigger node, and change the Trigger Type dropdown to "Webhook". Save again. ## Debugging: Empty Trigger Data If your HTTP Request step shows empty strings where you expected data: 1. **Check your curl command** — is the JSON valid? Missing quotes? 2. **Check the template syntax** — it's `{{@nodeId:Label.fieldName}}`, not `{{trigger.fieldName}}` 3. **Check the node ID** — click your trigger node and verify the ID matches (e.g., `trigger-1`) 4. **Check field names** — `.title` won't find `Title` (case matters) ## Commit ```bash git add -A git commit -m "feat: add webhook-triggered workflow with HTTP request" ``` ## Done - [ ] Changed trigger type to Webhook - [ ] Copied the webhook URL - [ ] Added HTTP Request action with template variables - [ ] POSTed JSON with curl - [ ] Saw instant response, workflow completed in background - [ ] Verified trigger data flowed to the HTTP Request step \*\*Note: Learn More\*\* The Workflow SDK provides powerful primitives for handling external events. See [Hooks & Webhooks](https://workflow-sdk.dev/docs/foundations/hooks) for patterns like waiting for multiple events, custom tokens, and manual response handling. ## What's Next Webhook triggers let external systems start your workflows. But the built-in actions (Log, HTTP Request, Condition) are limited. What if you want to send emails, post to Slack, create Linear tickets, or call your internal APIs? That's where plugins come in. Lesson 3 teaches you the plugin folder pattern — how to extend the workflow builder with your own actions. --- title: "Build Your First Plugin" description: "Build a simple plugin to learn how plugins work before adding API complexity. Understand the plugin folder pattern." canonical_url: "https://vercel.com/academy/visual-workflow-builder-on-vercel/first-plugin" md_url: "https://vercel.com/academy/visual-workflow-builder-on-vercel/first-plugin.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-05T23:49:21.805Z" content_type: "lesson" course: "visual-workflow-builder-on-vercel" course_title: "Build Visual Workflow Plugins on Vercel" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Build Your First Plugin # Build Your First Plugin You've run workflows and triggered them with webhooks. Now you'll extend the builder itself. Plugins are the power — they let you wire up any API, any service, any internal tool. But learning the plugin structure while also fighting an external API is too much at once. So we'll build something stupid simple first. No API calls. No credentials. Just a plugin that logs a configurable message. You'll learn the folder structure with zero external complexity. The starter stays intentionally minimal; plugin discovery is what makes new integrations show up. Then in the next lesson, you'll use `pnpm create-plugin` to scaffold automatically. \*\*Note: Mental Model: Steps and Flow\*\* Each node on the canvas becomes a step in code. The `"use step"` directive makes each one independently retryable and observable. What you drag is what runs. ## Outcome You'll create a working "Shout" plugin that takes a message and logs it in ALL CAPS. It'll appear in the action grid, show configurable fields in the UI, execute as a durable step, and show up in the logs. ## Fast Track 1. Write the step function (the core logic) 2. Create the plugin definition (tell the system it exists) 3. Run `pnpm discover-plugins` (auto-generates wiring) ## The Plugin Folder Pattern Every plugin lives in `plugins/[name]/` with this structure: ``` plugins/shout/ ├── index.ts → Plugin definition (registers with the system) ├── icon.tsx → Icon for the action grid ├── credentials.ts → Type definition for credentials ├── test.ts → Connection test (optional) └── steps/ └── shout.ts → The "use step" function (core logic) ``` We'll build these in order of importance: **step first** (the logic), **plugin definition second** (the registration), **wiring third** (the executor). Icon and credentials are one-liners. \*\*Note: There's a Template for This\*\* Check `plugins/_template/` — it has all these files ready to copy. The files end in `.txt` so they don't compile. For learning, we'll build by hand. In Lesson 4, you'll use `pnpm create-plugin` to scaffold automatically. \*\*Reflection:\*\* When you drag a 'Shout' node onto the canvas and click Run, what has to happen for your shoutStep function to actually execute? Think about: How does the workflow executor know which function to call? ## Hands-on Exercise We'll build the Shout plugin in three phases: core logic, registration, and wiring. ### Phase 1: The Step Function (Core Logic) The step function is where the real work happens. Everything else is just wiring to get here. **Create the folder and file:** ```bash mkdir -p plugins/shout/steps ``` **Write the step function** (`plugins/shout/steps/shout.ts`): ```typescript title="plugins/shout/steps/shout.ts" import "server-only"; import { type StepInput, withStepLogging } from "@/lib/steps/step-handler"; type ShoutInput = StepInput & { message: string; }; type ShoutResult = | { success: true; shouted: string } | { success: false; error: string }; async function stepHandler(input: ShoutInput): Promise { if (typeof input.message !== 'string' || !input.message.trim()) { return { success: false, error: 'Message must be a non-empty string' }; } const shouted = input.message.toUpperCase(); console.log(shouted); return { success: true, shouted }; } export async function shoutStep(input: ShoutInput): Promise { "use step"; return withStepLogging(input, () => stepHandler(input)); } export const _integrationType = "shout"; ``` **What this does:** - `"use step"` — marks this function as a durable step (retryable, observable). See [Workflows and Steps](https://workflow-sdk.dev/docs/foundations/workflows-and-steps) for how this directive works. - `withStepLogging` — wraps execution with timing and logging - `stepHandler` — the actual logic (uppercase the message) - Union return type — success OR error, never throw This is the pattern for every step you'll ever write. The logic is trivial; the structure is what matters. ### Phase 2: Plugin Definition (Registration) Now tell the system this plugin exists. **Create the plugin definition** (`plugins/shout/index.ts`): ```typescript title="plugins/shout/index.ts" import type { IntegrationPlugin } from "../registry"; import { registerIntegration } from "../registry"; import { ShoutIcon } from "./icon"; const shoutPlugin: IntegrationPlugin = { type: "shout", label: "Shout", description: "Log messages in ALL CAPS", icon: ShoutIcon, formFields: [], // No credentials needed actions: [ { slug: "shout", label: "Shout Message", description: "Log a message in uppercase", category: "Shout", stepFunction: "shoutStep", stepImportPath: "shout", configFields: [ { key: "message", label: "Message", type: "template-input", placeholder: "Enter message to shout", required: true, }, ], }, ], }; registerIntegration(shoutPlugin); export default shoutPlugin; ``` **The key fields:** - `type` + `slug` → full action ID is `"shout/shout"` - `configFields` → what shows in the properties panel - `stepFunction` → the exported function name from your step file **Add the icon** (`plugins/shout/icon.tsx`): ```tsx title="plugins/shout/icon.tsx" import { Megaphone } from "lucide-react"; export function ShoutIcon(props: React.ComponentProps) { return ; } ``` **Add empty credentials** (`plugins/shout/credentials.ts`): ```typescript title="plugins/shout/credentials.ts" export type ShoutCredentials = { // No credentials needed for this plugin }; ``` ### Phase 3: Registration (Auto-Discovery) Run the plugin discovery script: ```bash pnpm discover-plugins ``` This does three things automatically: ```mermaid flowchart TB A[pnpm discover-plugins] --> B[Scan plugins/*/] B --> C[plugins/index.ts] B --> D[lib/step-registry.ts] B --> E[lib/types/integration.ts] ``` 1. **Updates `plugins/index.ts`** — adds `import "./shout"` so your plugin registers on startup 2. **Updates `lib/step-registry.ts`** — adds the step importer so the executor can find your step 3. **Updates `lib/types/integration.ts`** — adds `"shout"` to the generated `IntegrationType` union \*\*Warning: Don't Edit Generated Files\*\* Never manually edit `plugins/index.ts`, `lib/step-registry.ts`, or `lib/types/integration.ts`. They're auto-generated by `discover-plugins`, and the starter now treats those generated files as the source of truth. Your changes will be overwritten. **Restart the dev server** (new files require restart): ```bash # Ctrl+C to stop, then: pnpm dev ``` ## Try It 1. Open the workflow builder 2. Click the **+** button to add an action 3. Find **Shout Message** in the action grid 4. Configure the message field: `hello workflow` 5. Run the workflow Check your terminal (where `pnpm dev` is running): ``` [Workflow Executor] Starting workflow execution [Workflow Executor] Executing trigger node [Workflow Executor] Executing action node: Shout Message [shout] Starting step execution... HELLO WORKFLOW [shout] Step completed successfully in 2ms [Workflow Executor] Workflow execution completed: { success: true, ... } ``` You should also see: - The step in the execution logs with timing - The action in the Code tab's generated workflow ```yaml quiz: question: "The action ID 'shout/shout' comes from combining which two fields in the plugin definition?" choices: - id: "a" text: "label + description" - id: "b" text: "type + slug" - id: "c" text: "stepFunction + stepImportPath" - id: "d" text: "category + label" correctAnswerId: "b" feedback: "{\n correct: \"Right. The full action ID is '[type]/[slug]'. In your plugin: type='shout', slug='shout' → 'shout/shout'. This is what the executor checks.\",\n incorrect: \"The action ID format is '[type]/[slug]'. Check your plugin definition — type is the integration identifier, slug is the specific action.\"\n }" ``` ## Debugging: "My Plugin Doesn't Show Up" If Shout isn't in the action grid, check in this order: **1. Did you run discover-plugins?** ```bash pnpm discover-plugins ``` Check the output — it should say "Found 1 plugin(s): shout" **2. Did you restart the dev server?** ```bash pnpm dev ``` **3. Check the generated files:** - `plugins/index.ts` should have `import "./shout";` - `lib/step-registry.ts` should have a `"shout/shout"` entry **4. Check your plugin definition matches:** ```typescript // In plugins/shout/index.ts: type: "shout", // integration type actions: [{ slug: "shout", // action slug stepFunction: "shoutStep", // must match export name stepImportPath: "shout", // must match filename (without .ts) }] ``` The action ID is `"shout/shout"` (type + "/" + slug). The step file must be at `plugins/shout/steps/shout.ts` and export `shoutStep`. ## The Five Files of a Workflow Plugin Here's what you built and why each exists: | File | Purpose | Required? | | ---------------- | ------------------------------- | -------------------- | | `steps/shout.ts` | Core logic with `"use step"` | Yes | | `index.ts` | Plugin definition, registration | Yes | | `icon.tsx` | Visual identifier in UI | Yes (can be generic) | | `credentials.ts` | Type safety for secrets | Yes (can be empty) | | `test.ts` | Connection validation | No | In Lesson 4, `pnpm create-plugin` generates all of these. But now you know what each one does. ## Solution The complete plugin code is shown in Phases 1-3 above. The validation check handles edge cases before they cause cryptic errors. In Lesson 5, you'll learn to throw `FatalError` for unrecoverable problems. ## Commit ```bash git add -A git commit -m "feat(plugins): add shout plugin - first custom plugin" ``` ## Done - [ ] Step function created with `"use step"` directive - [ ] Plugin definition with `configFields` for the message input - [ ] `pnpm discover-plugins` run successfully - [ ] Plugin appears in action grid - [ ] Workflow runs and logs ALL CAPS message - [ ] Step shows in execution logs with timing ## What's Next You built a plugin by hand. You understand the five files, the registration, the executor wiring. In Lesson 4, you'll use `pnpm create-plugin` to scaffold an email plugin automatically — then you'll focus on the interesting part: the Resend API integration and credential handling. \*\*Side Quest: Build the Reverse Plugin\*\* --- title: "Build an Email Plugin" description: "Build an email plugin using the pattern you learned. Add real API calls, credential management, and send actual emails through your workflow." canonical_url: "https://vercel.com/academy/visual-workflow-builder-on-vercel/resend-plugin" md_url: "https://vercel.com/academy/visual-workflow-builder-on-vercel/resend-plugin.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-05T23:49:21.825Z" content_type: "lesson" course: "visual-workflow-builder-on-vercel" course_title: "Build Visual Workflow Plugins on Vercel" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Build an Email Plugin # Build an Email Plugin You know the plugin folder pattern from the Shout plugin. Now apply it to something real — a plugin that sends actual emails. We'll use Resend (swap in SendGrid, Postmark, whatever you prefer). The new complexity: credentials. API keys shouldn't be passed as step inputs — they'd be serialized to the workflow's event log. You'll fetch them inside the step instead. \*\*Note: Building on Lesson 3\*\* Same folder structure as the Shout plugin. Same registration pattern. The new part: `fetchCredentials()` in your step to securely access API keys. ## Outcome You'll scaffold an email plugin, customize it with real Resend API calls, and send an actual email through your workflow. ## Fast Track 1. Get a Resend API key from [resend.com/api-keys](https://resend.com/api-keys) 2. Run `pnpm create-plugin` to scaffold the plugin 3. Customize the generated step with Resend API logic 4. Send a real email via workflow ## What's New: Credentials The Shout plugin had no secrets. Resend needs an API key. Here's the critical pattern: ```mermaid height=1000 flowchart TB A[Step executes] --> B{integrationId provided?} B -->|Yes| C[fetchCredentials from DB] B -->|No| D[Use process.env fallback] C --> E[Call Resend API] D --> E E --> F[Key stays inside step function] ``` ```typescript // ❌ BAD: Key gets serialized to workflow event log async function sendEmailStep(input: { apiKey: string, to: string }) { // apiKey is now persisted in the event log for replay } // ✅ GOOD: Fetch credentials inside the step async function sendEmailStep(input: { to: string }) { const apiKey = process.env.RESEND_API_KEY; // or fetchCredentials() // apiKey never leaves this function's scope } ``` ## Hands-on Exercise The scaffolding tool generates the plugin structure. Your job: customize the step function with real Resend API calls. This lesson assumes you're extending the stripped-down starter rather than uncovering a built-in email integration. ### 1. Scaffold the Plugin ```bash pnpm create-plugin ``` Answer the prompts: - **Integration name:** `resend` - **Description:** `Send emails via Resend` - **Action slug:** `send-email` - **Action description:** `Send an email` This creates the plugin folder: ``` plugins/resend/ ├── index.ts → Plugin definition with formFields, actions ├── icon.tsx → Icon component ├── credentials.ts → Type for credentials ├── test.ts → Connection test function └── steps/ └── send-email.ts → "use step" function (customize this) ``` ### 2. Add the Resend SDK ```bash pnpm add resend ``` ### 3. Customize the Step Function Open `plugins/resend/steps/send-email.ts`. The scaffolding generates a template — replace the API call with Resend's SDK: ```typescript title="plugins/resend/steps/send-email.ts" {3,12-16,27-49} import "server-only"; import { Resend } from "resend"; import { fetchCredentials } from "@/lib/credential-fetcher"; import { type StepInput, withStepLogging } from "@/lib/steps/step-handler"; import type { ResendCredentials } from "../credentials"; type SendEmailResult = | { success: true; id: string } | { success: false; error: string }; export type SendEmailCoreInput = { emailTo: string; emailSubject: string; emailBody: string; }; export type SendEmailInput = StepInput & SendEmailCoreInput & { integrationId?: string; }; async function stepHandler( input: SendEmailCoreInput, credentials: ResendCredentials ): Promise { const apiKey = credentials.RESEND_API_KEY; if (!apiKey) { return { success: false, error: "RESEND_API_KEY is not configured.", }; } const resend = new Resend(apiKey); const result = await resend.emails.send({ from: "onboarding@resend.dev", // Resend's test sender to: input.emailTo, subject: input.emailSubject, text: input.emailBody, }); if (result.error) { return { success: false, error: result.error.message }; } return { success: true, id: result.data?.id || "" }; } export async function sendEmailStep( input: SendEmailInput ): Promise { "use step"; // Fetch from integration, or fall back to env var for local dev let credentials: ResendCredentials; if (input.integrationId) { credentials = await fetchCredentials(input.integrationId) as ResendCredentials; } else { credentials = { RESEND_API_KEY: process.env.RESEND_API_KEY || "", }; } return withStepLogging(input, () => stepHandler( { emailTo: input.emailTo, emailSubject: input.emailSubject, emailBody: input.emailBody, }, credentials ) ); } export const _integrationType = "resend"; ``` ### 4. Update the Credentials Type ```typescript title="plugins/resend/credentials.ts" export type ResendCredentials = { RESEND_API_KEY?: string; }; ``` ### 5. Configure Environment Add your API key to `.env.local`: ```bash title=".env.local" RESEND_API_KEY=re_your_key_here ``` \*\*Note: Dev vs Production Credentials\*\* In this course, you're using environment variables in `.env.local` — the step code falls back to `process.env` when no `integrationId` is provided. In a production multi-tenant app, users would store their own API keys via **Settings → Integrations**. The `fetchCredentials()` function would retrieve them from the database. Same pattern, different credential source. See [Environment Variables](https://vercel.com/docs/environment-variables) and [Sensitive Environment Variables](https://vercel.com/docs/environment-variables/sensitive-environment-variables) for Vercel's best practices on managing secrets. ### 6. Restart the Dev Server ```bash # Stop the server (Ctrl+C), then: pnpm dev ``` ## Try It \*\*Reflection:\*\* Before you run: What will the step logs show? Will you see the API key in any log output? What will happen if the API key is wrong? \*\*Warning: Resend Test Sender Limitation\*\* The `onboarding@resend.dev` sender can only send to the email address you signed up with on Resend. Use your Resend account email in the "To" field. To send to other recipients, you'll need to verify your own domain at [resend.com/domains](https://resend.com/domains). 1. Add Send Email node after your trigger 2. Configure: **your Resend account email**, subject "Test from Workflow", body "It works!" 3. Run the workflow 4. Check your inbox (or Resend dashboard at [resend.com/emails](https://resend.com/emails)) 5. Check logs — note the step timing, but NO api key visible Your terminal should show: ``` [Workflow Executor] Starting workflow execution [Workflow Executor] Executing trigger node [Workflow Executor] Executing action node: resend/send-email [Workflow Executor] Step result received: { hasResult: true, resultType: 'object' } [Workflow Executor] Node execution completed: { nodeId: 'action-1', success: true } [Workflow Executor] Workflow execution completed: { success: true, ... } ``` Note: The API key is nowhere in that output. That's the whole point of fetching credentials inside the step. ## Debugging: "RESEND\_API\_KEY is not configured" Your first run will probably fail. In the Runs tab, expand the failed step and check the output: ```json { "error": "RESEND_API_KEY is not configured.", "success": false } ``` **The env var isn't loaded yet.** Next.js usually picks up `.env.local` changes automatically, but if it doesn't, restart the dev server: ```bash # Stop the server (Ctrl+C), then: pnpm dev ``` Run again. This time it works. \*\*Note: Real Debugging\*\* This isn't a contrived exercise — forgetting to restart after adding env vars is the #1 "why doesn't this work" moment in plugin development. Now you'll recognize it instantly. ```yaml quiz: question: "Why do we fetch credentials inside the step instead of passing them as input parameters?" choices: - id: "performance" text: "Fetching inside is faster" - id: "logs" text: "Input parameters get serialized to the workflow event log" - id: "syntax" text: "The SDK requires it" - id: "typing" text: "TypeScript types work better this way" correctAnswerId: "logs" feedback: "{\n correct: \"Bingo. Workflow serializes all step inputs to the event log for deterministic replay. Pass an API key as a parameter and it's persisted. Fetch inside the step and it never leaves that function's scope.\",\n incorrect: \"Think about how workflows achieve durability. Step inputs are serialized and persisted. What would happen if your API key was in that data?\"\n }" ``` ## Solution The complete step function handles both development and production credential sources. The dual-path credential pattern works like this: the `if (input.integrationId)` branch handles production multi-tenant apps where users store their own credentials via Settings → Integrations. The `else` branch handles local development with environment variables. This pattern works in both environments without code changes. Note that we return `{ success: false }` instead of throwing when credentials are missing. In Lesson 5, you'll refactor this to throw `FatalError` for missing credentials — making it explicit that this failure is permanent and shouldn't retry. ## Commit ```bash git add plugins/resend git commit -m "Add Resend email plugin with secure credential handling" ``` ## Done - [ ] Resend API key in `.env` - [ ] Plugin appears in action grid - [ ] Credentials fetched inside step (not passed as params) - [ ] Sent a real email - [ ] Verified no secrets in logs \*\*Side Quest: Swap the Provider\*\* --- title: "Break It, Fix It" description: "Master Vercel Workflow error handling by breaking things on purpose. Learn when to use RetryableError for transient failures and FatalError for permanent failures." canonical_url: "https://vercel.com/academy/visual-workflow-builder-on-vercel/error-handling" md_url: "https://vercel.com/academy/visual-workflow-builder-on-vercel/error-handling.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-05T23:49:21.844Z" content_type: "lesson" course: "visual-workflow-builder-on-vercel" course_title: "Build Visual Workflow Plugins on Vercel" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Break It, Fix It # Break It, Fix It Your email plugin from [Build an Email Plugin](/visual-workflow-builder-on-vercel/resend-plugin) works when everything goes right. But networks fail. APIs go down. Rate limits hit. Invalid credentials slip through. This is exactly why the stripped-down starter teaches the pattern first instead of pretending built-in integrations already solve it. What happens when your own plugin hits those failures? Vercel Workflow gives you explicit control: throw `RetryableError` for transient failures that should retry automatically, or `FatalError` for permanent failures that need immediate attention. You decide what retries and what stops. \*\*Note: Mental Model: Retry and Error Recovery\*\* `RetryableError` retries automatically with exponential backoff. `FatalError` stops immediately. You control which errors get which treatment. See [Errors & Retrying](https://workflow-sdk.dev/docs/foundations/errors-and-retries) in the SDK docs. ## Outcome You'll break your email plugin on purpose, see the limitation of simple return values, then refactor to proper error handling with `FatalError` and `RetryableError`. The goal: understand *why* the SDK provides these error types. ## Fast Track 1. Break the email plugin with a bad API key - see the return-value limitation 2. Refactor to `FatalError` for auth failures, `RetryableError` for transient ones 3. Fix and verify the improved error handling ## Hands-on Exercise \*\*Reflection:\*\* Before you break it: If you set an invalid API key and run the workflow, how many times do you think the step will attempt? Will it retry forever, or stop after some limit? What will the logs show? ### Part 1: Break It (See the Problem) Your step from [Build an Email Plugin](/visual-workflow-builder-on-vercel/resend-plugin) returns `{ success: false, error: "..." }` when things go wrong. Let's see what happens: 1. Open `.env.local` and set `RESEND_API_KEY=invalid_key_12345` 2. Run your Send Email workflow 3. Check the Runs tab output: ```json { "error": "API key is invalid", "success": false } ``` One attempt. Failed. Done. The workflow has no idea this was an auth error vs a rate limit vs a network blip. It just sees "failed" and stops. **The problem:** Your step returns failure, but doesn't tell the workflow *how* to handle it. Should it retry? Alert immediately? The workflow can't decide because you haven't told it. ### Part 2: Refactor to Throw Errors Let's upgrade your step to use proper Workflow SDK error types. Update `plugins/resend/steps/send-email.ts`: ```typescript title="plugins/resend/steps/send-email.ts" {1,9-11,21-24} import { FatalError } from "workflow"; async function stepHandler( input: SendEmailCoreInput, credentials: ResendCredentials ): Promise { const apiKey = credentials.RESEND_API_KEY; if (!apiKey) { throw new FatalError("RESEND_API_KEY is not configured"); } const resend = new Resend(apiKey); const result = await resend.emails.send({ from: "onboarding@resend.dev", to: input.emailTo, subject: input.emailSubject, text: input.emailBody, }); if (result.error) { // Auth errors are permanent - don't retry if (result.error.message.includes("API key")) { throw new FatalError(`Auth failed: ${result.error.message}`); } // Other errors might be transient - return failure for now return { success: false, error: result.error.message }; } return { success: true, id: result.data?.id || "" }; } ``` Run with the invalid key again: ``` [Workflow Executor] Node execution completed: { nodeId: 'action-1', success: false } ``` Still one attempt, but now the error is a `FatalError` - the workflow knows this is permanent and won't waste time retrying. ### Part 3: Add Retry for Transient Errors Now let's handle the opposite case - errors that *should* retry. Rate limits (429) and service unavailable (503) are temporary. Add `RetryableError`: ```typescript title="plugins/resend/steps/send-email.ts" {1,15-17} import { FatalError, RetryableError } from "workflow"; async function stepHandler( input: SendEmailCoreInput, credentials: ResendCredentials ): Promise { // ... apiKey check with FatalError ... const resend = new Resend(apiKey); const result = await resend.emails.send({ ... }); if (result.error) { const msg = result.error.message; // Transient errors - retry with backoff if (msg.includes("rate limit") || msg.includes("503")) { throw new RetryableError(`Temporary failure: ${msg}`); } // Auth errors - don't retry if (msg.includes("API key")) { throw new FatalError(`Auth failed: ${msg}`); } return { success: false, error: msg }; } return { success: true, id: result.data?.id || "" }; } ``` \*\*Note: Testing Retries\*\* To see retries in action, you can temporarily force a `RetryableError` at the start of your step. The workflow will retry with exponential backoff until it succeeds or hits the retry limit. ### Part 4: Fix and Verify 1. Restore your valid `RESEND_API_KEY` in `.env.local` 2. Run the workflow 3. Watch it succeed on first attempt 4. Check the Runs tab - you should see `{ "success": true, "id": "..." }` ## When to Use Which ```mermaid height=750 flowchart TB A[API returns error] --> B{Will retry fix it?} B -->|Yes: 429, 503, timeout| C[RetryableError] B -->|No: 401, 400, bad data| D[FatalError] C --> E[Auto-retry with backoff] D --> F[Stop immediately + alert] ``` | Error Type | When to Use | Examples | | ---------------------------------------------------------------------------------------- | ------------------------------------------ | -------------------------------------------------------- | | [`RetryableError`](https://workflow-sdk.dev/docs/api-reference/workflow/retryable-error) | Transient failures that might resolve | 429 rate limit, 503 service unavailable, network timeout | | [`FatalError`](https://workflow-sdk.dev/docs/api-reference/workflow/fatal-error) | Permanent failures that won't self-resolve | 401 unauthorized, 400 bad request, invalid input data | \*\*Warning: Don't Retry Auth Failures\*\* A bad API key won't become valid after 3 retries. Make auth failures fatal immediately — you'll get alerted faster and won't waste resources. \*\*Note: Production Observability\*\* In production, workflow errors show up in [Vercel Runtime Logs](https://vercel.com/docs/logs/runtime). Set up [Log Drains](https://vercel.com/docs/drains) to pipe them to your observability stack, and configure [Alerts](https://vercel.com/docs/alerts) to get notified when fatal errors spike. ```yaml quiz: question: "Your step gets a 503 Service Unavailable from an external API. Which error type?" choices: - id: "fatal" text: "FatalError — the service is down" - id: "retryable" text: "RetryableError — service might recover" - id: "none" text: "No error — return a failure result instead" - id: "depends" text: "It depends on the API" correctAnswerId: "retryable" feedback: "{\n correct: \"Right. 503 is transient — the service is temporarily unavailable but will likely recover. RetryableError lets the workflow try again after backoff.\",\n incorrect: \"503 Service Unavailable is the textbook transient error. The service is down now but probably won't be in 30 seconds. That's exactly when you want automatic retry.\"\n }" ``` ```yaml quiz: question: "Your step gets a 401 Unauthorized. Which error type?" choices: - id: "retryable" text: "RetryableError — maybe the token will refresh" - id: "fatal" text: "FatalError — bad credentials won't fix themselves" - id: "none" text: "No error — return a failure result instead" - id: "depends" text: "It depends on the auth type" correctAnswerId: "fatal" feedback: "{\n correct: \"Exactly. A bad API key won't become valid after 3 retries. FatalError stops immediately so you get alerted and don't waste resources.\",\n incorrect: \"401 means your credentials are wrong. No amount of waiting will fix that. Make it fatal so you find out immediately.\"\n }" ``` \*\*Reflection:\*\* Think about an API you use regularly (Stripe, Twilio, GitHub, your internal services). List 2-3 error responses that API returns. For each one, would you use RetryableError or FatalError? Why? ## Try It Check the Runs tab after each test: **1. Before refactor (return pattern) - invalid API key:** ```json { "error": "API key is invalid", "success": false } ``` One attempt. Workflow doesn't know if it should retry. **2. After adding FatalError - invalid API key:** ```json { "error": "Auth failed: API key is invalid", "success": false } ``` Still one attempt, but now it's explicit - workflow knows not to retry auth failures. **3. After fixing - valid API key:** ```json { "id": "1b588f42-6550-469b-b3af-2b422ac51993", "success": true } ``` Success on first attempt. Email delivered. \*\*Note: Advanced: Custom Retry Timing\*\* `RetryableError` accepts a `retryAfter` option for precise control over when to retry. You can specify a duration string (`"5m"`), milliseconds (`5000`), or a specific `Date`. Combined with [`getStepMetadata()`](https://workflow-sdk.dev/docs/api-reference/workflow/get-step-metadata) for attempt counts, you can implement exponential backoff or honor `Retry-After` headers from APIs. See the [RetryableError docs](https://workflow-sdk.dev/docs/api-reference/workflow/retryable-error) for examples. ## Commit ```bash git add -A git commit -m "feat: add error handling with RetryableError and FatalError" ``` ## Done - [ ] Broke email plugin with invalid API key - [ ] Saw the limitation of return-value error pattern - [ ] Refactored to throw `FatalError` for auth failures - [ ] Added `RetryableError` for transient failures (rate limits, 503) - [ ] Fixed everything, verified successful send - [ ] Can explain when to use RetryableError vs FatalError \*\*Side Quest: Step Error Test Suite\*\* --- title: "Build Your Own Plugin" description: "Build a custom Vercel Workflow plugin for Slack, Stripe, GitHub, or any API you use. Apply everything you've learned to create a production-ready integration." canonical_url: "https://vercel.com/academy/visual-workflow-builder-on-vercel/build-your-plugin" md_url: "https://vercel.com/academy/visual-workflow-builder-on-vercel/build-your-plugin.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-05T23:49:21.872Z" content_type: "lesson" course: "visual-workflow-builder-on-vercel" course_title: "Build Visual Workflow Plugins on Vercel" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Build Your Own Plugin # Build Your Own Plugin Time to fly solo. You've followed the steps, copied the code, fixed the errors. Now you build something the tutorial didn't plan for — YOUR plugin, YOUR API, YOUR problem. This is where "I took a course" becomes "I can build this." You've built two plugins — Shout (toy) and email (real). You know the plugin folder pattern, how credentials work, how errors should behave. This is graduation: same patterns, no more hand-holding. \*\*Note: Mental Model: Workflow Thinking\*\* You can look at any async problem and see steps, pause points, failure modes. That's the skill — not just using this builder, but thinking in workflows. For deeper patterns, explore [Building AI Agents with Vercel Workflow](https://vercel.com/kb/guide/how-to-build-ai-agents-with-vercel-and-the-ai-sdk) and [Stateful Slack Bots with Vercel Workflow](https://vercel.com/kb/guide/stateful-slack-bots-with-vercel-workflow). ## Outcome You'll create a complete plugin for a service you actually use — Slack, Stripe, GitHub, your internal API — and run it in a workflow that does something useful for you. ## Fast Track 1. Deploy the production template (or continue on starter) 2. Run `pnpm create-plugin` to scaffold your plugin 3. Implement the step, test connection, and error handling ## Choose Your Path You've been building on the starter template. For your own plugin, you have three options: ### Continue on Starter Keep building where you are. The starter is intentionally minimal, but it has the same plugin patterns and discovery flow you've used throughout the course. Good for learning, quick experiments, or if you just want to finish what you started. ### Deploy Template Fresh (Recommended for Production) The production template includes more plugins to study, a cleaner codebase, and is ready for real use: [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel-labs%2Fworkflow-builder-template\&project-name=workflow-builder\&repository-name=workflow-builder\&demo-title=Workflow+Builder\&demo-description=A+free%2C+open-source+template+for+building+visual+workflow+automation+platforms+with+real+integrations+and+code+generation\&demo-url=https%3A%2F%2Fworkflow-builder-template.vercel.app\&demo-image=https%3A%2F%2Fraw.githubusercontent.com%2Fvercel-labs%2Fworkflow-builder-template%2Fmain%2Fscreenshot.png\&env=BETTER_AUTH_SECRET%2CINTEGRATION_ENCRYPTION_KEY%2CAI_GATEWAY_API_KEY\&envDescription=BETTER_AUTH_SECRET+and+INTEGRATION_ENCRYPTION_KEY+are+required+secrets.+AI_GATEWAY_API_KEY+is+optional.\&stores=%5B%7B%22type%22%3A%22postgres%22%7D%5D) This gives you a fresh Vercel project with Postgres provisioned automatically. The production template includes additional features like overlay-based UI, public workflow sharing, API keys management, and OG image generation. See the [workflow-builder-template repository](https://github.com/vercel-labs/workflow-builder-template) for the full feature set. ### GitHub Template (Clone Locally First) Want to explore the code before deploying? Create a repo from the template: [Use this template on GitHub](https://github.com/new?template_name=workflow-builder-template\&template_owner=vercel-labs) Clone it, poke around, then deploy when ready. ## Why This Lesson Matters Tutorials are passive. You follow steps, things work, you move on. Retention is maybe 20%. Building YOUR plugin, for YOUR API, when the docs don't map perfectly? That's where friction happens. You'll debug something weird. You'll make a decision the tutorial didn't cover. You'll come out knowing this system instead of just recognizing it. This is the difference between "I took a course" and "I can build this." ## Study These Production Plugins The production template includes several real-world plugins. Before building your own, study how these handle common patterns there — not in the stripped-down starter: | Plugin | What to Learn | | ------------- | --------------------------------------------------------------- | | **Resend** | Clean credential handling, simple single-action plugin | | **Slack** | OAuth-style tokens, message formatting | | **Linear** | Multiple actions (create ticket, find issues), API with GraphQL | | **Firecrawl** | Scraping and search actions, handling complex API responses | Each follows the same folder structure. Read one thoroughly — the patterns repeat. ## Plugin Folder Checklist The production template uses a streamlined structure: ``` plugins/[your-plugin]/ ├── index.ts → Plugin definition, registration ├── icon.tsx → Icon component ├── credentials.ts → Credential type definition ├── test.ts → Connection test function └── steps/ └── [action].ts → "use step" function with fetchCredentials() ``` Run `pnpm create-plugin` to scaffold this structure automatically. \*\*Reflection:\*\* Before you start building, answer these questions for your chosen API: (1) What's the main action your plugin will perform? (2) What credentials does it need? (3) What inputs should the config UI collect? (4) What can fail, and which failures are retryable vs fatal? Write this down — it's your design doc. ## Hands-on Exercise **1. Pick your API:** - **Your internal service (recommended)** — the real test is integrating something the template doesn't have - Stripe (create charge, refund, subscription) - Twilio (send SMS, make call) - GitHub (create issue, comment on PR) - Any API you actually use at work **2. Design before you code:** - What inputs does the UI need? - What can fail? Which failures are retryable? - What should the step output for downstream nodes? **3. Build the plugin:** Run `pnpm create-plugin` to scaffold, then customize. Here's the skeleton you'll fill in: ```typescript title="plugins/[your-plugin]/steps/[action].ts" import "server-only"; import { FatalError, RetryableError } from "workflow"; import { fetchCredentials } from "@/lib/credential-fetcher"; import { type StepInput, withStepLogging } from "@/lib/steps/step-handler"; import type { YourCredentials } from "../credentials"; type YourActionResult = | { success: true; /* your output fields */ } | { success: false; error: string }; type YourActionInput = StepInput & { // Your config fields from index.ts }; async function stepHandler( input: YourActionInput, credentials: YourCredentials ): Promise { // 1. Validate credentials if (!credentials.API_KEY) { throw new FatalError("API_KEY is not configured"); } // 2. Call your API const response = await fetch("https://your-api.com/endpoint", { method: "POST", headers: { Authorization: `Bearer ${credentials.API_KEY}` }, body: JSON.stringify({ /* your payload */ }), }); // 3. Handle errors by type if (response.status === 401) { throw new FatalError("Invalid API key"); } if (response.status === 429 || response.status >= 500) { throw new RetryableError(`API returned ${response.status}`); } // 4. Return success const data = await response.json(); return { success: true, /* your output fields */ }; } export async function yourActionStep( input: YourActionInput ): Promise { "use step"; const credentials = input.integrationId ? await fetchCredentials(input.integrationId) : {}; return withStepLogging(input, () => stepHandler(input, credentials)); } export const _integrationType = "your-plugin"; ``` The key sections: - Validate credentials exist (FatalError if missing) - Call your API - Handle errors by type (401 = Fatal, 429/5xx = Retryable) - Return success with output for downstream nodes **4. Test the full flow:** - Add your plugin to a workflow - Run it - Break it on purpose (bad credentials) - Verify retries or fatal errors work as expected - Fix it and run successfully ## Verify It Works Your plugin is ready when: 1. **Happy path succeeds** — Run your workflow and check the Runs tab: ```json { "success": true, // your output fields from step return } ``` 2. **Bad credentials fail fast** — Remove or invalidate your API key, run again. You should see `FatalError` logged immediately, not retry attempts. 3. **Transient errors retry** — If your API returns 429 or 503, the workflow should retry with backoff (check the step execution count in logs). If all three work, you've internalized the pattern. Ship it. ## Learn More Now that you've built real plugins, go deeper with these resources: | Resource | What You'll Learn | | ----------------------------------------------------------------- | -------------------------------------------- | | [Workflow SDK Docs](https://workflow-sdk.dev) | Complete API reference, advanced patterns | | [Vercel Workflow Platform Docs](https://vercel.com/docs/workflow) | Deployment, observability, production config | | [Vercel Functions](https://vercel.com/docs/functions) | The runtime powering your workflow steps | | [Fluid Compute](https://vercel.com/docs/fluid-compute) | How Vercel optimizes function execution | ## Done - [ ] Picked a real API you actually use - [ ] Built the plugin folder structure - [ ] Plugin appears in action grid - [ ] Workflow runs successfully with your plugin - [ ] Tested error handling (retries or fatal) - [ ] Credentials stay out of logs ## What You've Learned You started with a deploy button and ended with a custom integration. Along the way: - **Keep Your API Fast** — Routes return instantly, workflows run in background - **Pause and Continue** — Workflows suspend for webhooks, resume exactly - **Steps and Flow** — Code matches diagram, steps are composable promises - **Retry and Error Recovery** — You control what retries and what stops - **Step Tracing** — Observability comes free, correlation IDs thread everything Now go build something real. When you do, [share it in the Vercel Community](https://community.vercel.com/) — we want to see what you create. --- title: "Vercel Sandbox" description: "Learn how to safely execute untrusted code using Vercel Sandbox. Build a CLI code review agent that clones repositories, runs tests, and uses AI to analyze code for security and quality issues, all in an isolated environment." canonical_url: "https://vercel.com/academy/vercel-sandbox" md_url: "https://vercel.com/academy/vercel-sandbox.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-09-22T04:50:21.869Z" content_type: "course" lessons: 20 estimated_time: lesson_urls: - "https://vercel.com/academy/vercel-sandbox/your-first-sandbox.md" - "https://vercel.com/academy/vercel-sandbox/clone-a-repo.md" - "https://vercel.com/academy/vercel-sandbox/read-files.md" - "https://vercel.com/academy/vercel-sandbox/wrap-the-lifecycle.md" - "https://vercel.com/academy/vercel-sandbox/scaffold-the-cli.md" - "https://vercel.com/academy/vercel-sandbox/validate-github-urls.md" - "https://vercel.com/academy/vercel-sandbox/wire-the-sandbox-workflow.md" - "https://vercel.com/academy/vercel-sandbox/predictable-exit-codes.md" - "https://vercel.com/academy/vercel-sandbox/the-naive-prompt.md" - "https://vercel.com/academy/vercel-sandbox/design-the-review-schema.md" - "https://vercel.com/academy/vercel-sandbox/generate-structured-reviews.md" - "https://vercel.com/academy/vercel-sandbox/connect-analysis-to-the-cli.md" - "https://vercel.com/academy/vercel-sandbox/run-tests-inside-the-sandbox.md" - "https://vercel.com/academy/vercel-sandbox/parse-test-failures.md" - "https://vercel.com/academy/vercel-sandbox/handle-package-manager-variants.md" - "https://vercel.com/academy/vercel-sandbox/merge-ai-and-test-findings.md" - "https://vercel.com/academy/vercel-sandbox/benchmark-the-pipeline.md" - "https://vercel.com/academy/vercel-sandbox/sandbox-snapshots-for-speed.md" - "https://vercel.com/academy/vercel-sandbox/resilient-error-handling.md" - "https://vercel.com/academy/vercel-sandbox/formatted-reports.md" --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Vercel Sandbox AI is writing more of your code. Claude Code oneshotting, ChatGPT copy and paste, forgetting what an IDE looks like. But running code you didn't write is risky. What if there are bugs? What if there are security vulnerabilities? Before you YOLO everything into production, Vercel Sandbox lets you execute untrusted code in isolated microVMs that spin up in milliseconds. In this course, you'll build a CLI agent that doesn't just read code, it actually runs it, safely. ## What you'll build A CLI tool that accepts a GitHub repo URL, clones the repository into an isolated Sandbox environment, runs its test suite, and uses AI to review the code for security and quality issues. By the end, you'll have a tool that combines static analysis with dynamic test execution into a unified review. ## What you'll learn This course covers 5 sections that progressively build a CLI code review agent: - **Section 1: Sandbox Foundations** - Create sandboxes, run commands, clone repos, and read files in isolated environments - **Section 2: Building the CLI** - Set up a CLI project with commander, validate GitHub URLs, and wire up the Sandbox workflow - **Section 3: AI-Powered Analysis** - Use AI Gateway to read code files, build analysis prompts, and generate security and quality reviews - **Section 4: Test Execution** - Run test suites in the Sandbox, handle failures, and combine static and dynamic findings - **Section 5: Production Ready** - Optimize with snapshots, add error handling, and polish the output ## Prerequisites - Comfortable with [TypeScript](https://www.typescriptlang.org/) and [Node.js](https://nodejs.org/) - Basic familiarity with CLI tools - [Vercel](https://vercel.com/) account ## Tech stack - [`@vercel/sandbox`](https://vercel.com/docs/sandbox) - Isolated microVMs for code execution - [AI SDK](https://sdk.vercel.ai/) with AI Gateway - [TypeScript](https://www.typescriptlang.org/) - [`commander`](https://github.com/tj/commander.js) - CLI framework - [`pnpm`](https://pnpm.io/) - Package manager - [`git`](https://git-scm.com/) - Repository cloning ## How this course works - **Build everything yourself:** Type every line of code so you understand how everything works - **Real code execution:** Every section runs actual commands in real sandboxes - **Progressive complexity:** Each section adds one new capability - **Production patterns only:** Everything you build is something you'd actually ship ## Getting started Ready to safely execute untrusted code? Start with **Sandbox Foundations** to learn the basics of isolated environments. --- title: "Your First Sandbox" description: "Install the Sandbox SDK, create your first Sandbox, run a single command inside the microVM, and stop it. The simplest possible round-trip before we add anything to it." canonical_url: "https://vercel.com/academy/vercel-sandbox/your-first-sandbox" md_url: "https://vercel.com/academy/vercel-sandbox/your-first-sandbox.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-21T16:55:06.907Z" content_type: "lesson" course: "vercel-sandbox" course_title: "Vercel Sandbox" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Your First Sandbox # Spin Up Your First Sandbox Running unknown code on your laptop is like inviting a raccoon into your kitchen and hoping it respects boundaries. It will not. So we start small. Before we clone repos or run AI analysis or anything fun, we need to prove that we can create a Sandbox, run one command inside it, and stop it without leaving anything behind. That's it. One command, one microVM, one clean exit. ## Outcome Create a Sandbox, run a single `echo` command inside it, and stop it cleanly. ## Fast Track 1. Install `@vercel/sandbox` and load your auth env vars. 2. Call `Sandbox.create({ persistent: false })` and `runCommand('echo', ['hello'])`. 3. Call `sandbox.stop()` and confirm it exits with no errors. ## Hands-on exercise Create `src/sandbox-lifecycle.ts`. We're going to keep growing this file across the next four lessons, so give it the name it'll have at the end. For local authentication, link the directory to a Vercel project and pull a development OIDC token: ```bash vercel link vercel env pull .env ``` Run the TypeScript examples with that env file loaded. The same OIDC token authenticates both Sandbox and AI Gateway during local development. ```ts import { Sandbox } from '@vercel/sandbox'; async function main() { const sandbox = await Sandbox.create({ persistent: false, timeout: 10 * 60 * 1000 }); console.log(`Sandbox created: ${sandbox.name}`); const result = await sandbox.runCommand('echo', ['hello from inside the sandbox']); console.log(`Output: ${(await result.stdout()).trim()}`); console.log(`Exit code: ${result.exitCode}`); await sandbox.stop(); console.log('Sandbox stopped.'); } main(); ``` No `try/finally`, no error handling, no helpers. We're proving the round-trip works before we add anything to it. If this lesson errors out, every later lesson breaks in the same way, and we want to see that now. \*\*Warning: Troubleshooting: auth errors on create\*\* `Sandbox.create()` needs Vercel auth. If you see a 401 or "no credentials", run `vercel link` and `vercel env pull .env` again, then load `.env` when you run the script. Do not print tokens into your terminal history. \*\*Note: Troubleshooting: hangs on create\*\* Sandbox creation usually takes a couple seconds. If it hangs for more than \~30s, there's likely a network or auth issue. Stop the process and check your env before retrying. ## Try It ```bash pnpm tsx src/sandbox-lifecycle.ts ``` Expected output: ```txt Sandbox created: repo-review-... Output: hello from inside the sandbox Exit code: 0 Sandbox stopped. ``` If you see a sandbox name, a hello, an exit code of 0, and a clean stop, you've got the foundation. Everything else in the course is more of this. ## Commit ```bash git add src/sandbox-lifecycle.ts git commit -m "feat(sandbox): create, run, and stop a minimal sandbox" ``` ## Done-When - [ ] `Sandbox.create({ persistent: false })` returns a sandbox with a name - [ ] `runCommand('echo', ['...'])` returns exit code 0 - [ ] `sandbox.stop()` completes without throwing - [ ] The script exits on its own (no hanging process) ## Solution ```ts title="src/sandbox-lifecycle.ts" import { Sandbox } from '@vercel/sandbox'; async function main() { const sandbox = await Sandbox.create({ persistent: false, timeout: 10 * 60 * 1000 }); console.log(`Sandbox created: ${sandbox.name}`); const result = await sandbox.runCommand('echo', ['hello from inside the sandbox']); console.log(`Output: ${(await result.stdout()).trim()}`); console.log(`Exit code: ${result.exitCode}`); await sandbox.stop(); console.log('Sandbox stopped.'); } main(); ``` --- title: "Clone a Repo" description: "Echoing strings is fine, but the whole point is to inspect real code. In this lesson, we run `git clone` inside the Sandbox, verify the exit code, and confirm the repo actually landed where we expected it." canonical_url: "https://vercel.com/academy/vercel-sandbox/clone-a-repo" md_url: "https://vercel.com/academy/vercel-sandbox/clone-a-repo.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-21T16:55:06.933Z" content_type: "lesson" course: "vercel-sandbox" course_title: "Vercel Sandbox" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Clone a Repo # Clone a Repo into the Sandbox Echoing strings is cute, but nobody hires you to print "hello." We want to look at real code, in isolation, without trusting it. Step one is getting that code into the Sandbox. Git is already available inside the Sandbox base image. Cloning is just another `runCommand`. The interesting part isn't the clone itself, it's verifying that the clone actually worked. ## Outcome Run `git clone` inside the Sandbox against a real GitHub URL, capture the exit code, and confirm the repo landed in the expected directory. ## Fast Track 1. Pick a public repo URL (we'll use `https://github.com/vercel/examples`). 2. Run `git clone repo` inside the Sandbox. 3. Check the exit code and log a confirmation. ## Hands-on exercise Open `src/sandbox-lifecycle.ts` and replace the echo with a clone: ```ts import { Sandbox } from '@vercel/sandbox'; const REPO_URL = 'https://github.com/vercel/examples'; async function main() { const sandbox = await Sandbox.create({ persistent: false, timeout: 10 * 60 * 1000 }); console.log(`Sandbox created: ${sandbox.name}`); const clone = await sandbox.runCommand('git', ['clone', '--depth', '1', REPO_URL, 'repo']); console.log(`Clone exit code: ${clone.exitCode}`); if (clone.exitCode !== 0) { console.error(`Clone failed: ${await clone.stderr()}`); } else { console.log(`Cloned ${REPO_URL} into repo`); } await sandbox.stop(); } main(); ``` Notice we're checking `exitCode` instead of assuming success. `runCommand` doesn't throw when the underlying command fails. It returns the exit code immediately; output is available through async methods such as `await clone.stderr()`. The URL is a separate argument instead of part of a shell string. That prevents repository input from being interpreted as shell syntax, even if another caller bypasses the CLI's URL validation later. Try breaking it on purpose. Change the URL to `https://github.com/this-does-not-exist/nope` and run it again. You'll see a non-zero exit code and a "Repository not found" message in `stderr`. That's the shape of failure we'll keep checking for. \*\*Warning: Troubleshooting: clone hangs\*\* If the clone hangs for more than \~60s, the Sandbox image may be missing `git` or the URL is unreachable from inside the microVM. Run `which git` as a quick `runCommand` to confirm git is present. \*\*Note: Troubleshooting: cloning private repos\*\* Public repos clone fine over HTTPS with no credentials. Private repos need an auth token, which we won't cover in this course. Stick to public URLs. ## Try It ```bash pnpm tsx src/sandbox-lifecycle.ts ``` Expected output (success case): ```txt Sandbox created: repo-review-... Clone exit code: 0 Cloned https://github.com/vercel/examples into repo ``` And the failure case (bad URL): ```txt Sandbox created: sbx_7N2k4A... Clone exit code: 128 Clone failed: fatal: repository 'https://github.com/this-does-not-exist/nope/' not found ``` Both are good. The first proves the happy path works; the second proves we notice when it doesn't. ## Commit ```bash git add src/sandbox-lifecycle.ts git commit -m "feat(sandbox): clone a repo and check the exit code" ``` ## Done-When - [ ] `git clone` runs inside the Sandbox - [ ] Exit code is captured and printed - [ ] Success case logs a confirmation - [ ] Failure case logs `stderr` instead of silently passing ## Solution ```ts title="src/sandbox-lifecycle.ts" import { Sandbox } from '@vercel/sandbox'; const REPO_URL = 'https://github.com/vercel/examples'; async function main() { const sandbox = await Sandbox.create({ persistent: false, timeout: 10 * 60 * 1000 }); console.log(`Sandbox created: ${sandbox.name}`); const clone = await sandbox.runCommand('git', ['clone', '--depth', '1', REPO_URL, 'repo']); console.log(`Clone exit code: ${clone.exitCode}`); if (clone.exitCode !== 0) { console.error(`Clone failed: ${await clone.stderr()}`); } else { console.log(`Cloned ${REPO_URL} into repo`); } await sandbox.stop(); } main(); ``` --- title: "Read Files" description: "We've cloned the repo, but we haven't looked inside. In this lesson, we list the cloned directory with `ls`, read the README out of the Sandbox, and handle the case where the file isn't there." canonical_url: "https://vercel.com/academy/vercel-sandbox/read-files" md_url: "https://vercel.com/academy/vercel-sandbox/read-files.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-21T16:55:06.960Z" content_type: "lesson" course: "vercel-sandbox" course_title: "Vercel Sandbox" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Read Files # Read Files from the Sandbox We cloned a repo. Now we should probably look at what we got. There are two ways to inspect a cloned project from outside the Sandbox: run a shell command and parse the output, or read files directly with the SDK. We're going to do both, because both come in handy. ## Outcome List the cloned repo's contents with `ls`, read a file with `sandbox.readFileToBuffer`, and handle the case where the file you wanted doesn't exist. ## Fast Track 1. Run `ls -la repo` and log the output. 2. Read `repo/README.md` with `sandbox.readFileToBuffer`. 3. Add a fallback so a missing file doesn't crash the script. ## Hands-on exercise Extend `src/sandbox-lifecycle.ts` after the clone step: ```ts import { Sandbox } from '@vercel/sandbox'; const REPO_URL = 'https://github.com/vercel/examples'; async function main() { const sandbox = await Sandbox.create({ persistent: false, timeout: 10 * 60 * 1000 }); console.log(`Sandbox created: ${sandbox.name}`); const clone = await sandbox.runCommand('git', ['clone', '--depth', '1', REPO_URL, 'repo']); if (clone.exitCode !== 0) { console.error(`Clone failed: ${await clone.stderr()}`); await sandbox.stop(); return; } const ls = await sandbox.runCommand('ls', ['-la', 'repo']); console.log('--- repo contents ---'); console.log(await ls.stdout()); let readmePreview = '(no README found)'; const readme = await sandbox.readFileToBuffer({ path: 'repo/README.md' }); if (readme) { readmePreview = readme.toString('utf8').slice(0, 300); } console.log('--- README preview ---'); console.log(readmePreview); await sandbox.stop(); } main(); ``` Two things to notice. First, `readFileToBuffer` accepts a `{ path }` object and resolves to `null` when the file is missing. Some repos use lowercase `readme.md`, some put docs in a `docs/` folder, and some don't have one at all. A null check keeps the script moving. Second, we bailed early on a failed clone. Continuing past a failed clone means everything after it fails for confusing reasons. Fail fast, fail loud. \*\*Warning: Troubleshooting: readFileToBuffer returns null\*\* `null` means the path was not found. An empty Buffer means the file exists but is empty. Check the exact filename in the `ls` output. \*\*Note: Troubleshooting: path mismatch\*\* `readFileToBuffer({ path: 'repo/README.md' })` is case-sensitive on the Sandbox filesystem. If `ls` shows `Readme.md` or `readme.md`, match the case exactly. ## Try It ```bash pnpm tsx src/sandbox-lifecycle.ts ``` Expected output: ```txt Sandbox created: sbx_7N2k4A... --- repo contents --- total 32 drwxr-xr-x ... .git -rw-r--r-- ... README.md -rw-r--r-- ... package.json drwxr-xr-x ... examples --- README preview --- # Vercel Examples This repository contains a set of example projects... ``` If `README.md` doesn't exist in the repo you're testing, you'll see `(no README found)` instead of crashing. That's the point. ## Commit ```bash git add src/sandbox-lifecycle.ts git commit -m "feat(sandbox): list directory and read files from sandbox" ``` ## Done-When - [ ] `ls -la repo` returns the cloned directory listing - [ ] `sandbox.readFileToBuffer` returns the README contents on a real repo - [ ] Missing README logs a warning instead of crashing - [ ] The script still stops the Sandbox at the end ## Solution ```ts title="src/sandbox-lifecycle.ts" import { Sandbox } from '@vercel/sandbox'; const REPO_URL = 'https://github.com/vercel/examples'; async function main() { const sandbox = await Sandbox.create({ persistent: false, timeout: 10 * 60 * 1000 }); console.log(`Sandbox created: ${sandbox.name}`); const clone = await sandbox.runCommand('git', ['clone', '--depth', '1', REPO_URL, 'repo']); if (clone.exitCode !== 0) { console.error(`Clone failed: ${await clone.stderr()}`); await sandbox.stop(); return; } const ls = await sandbox.runCommand('ls', ['-la', 'repo']); console.log('--- repo contents ---'); console.log(await ls.stdout()); let readmePreview = '(no README found)'; const readme = await sandbox.readFileToBuffer({ path: 'repo/README.md' }); if (readme) { readmePreview = readme.toString('utf8').slice(0, 300); } console.log('--- README preview ---'); console.log(readmePreview); await sandbox.stop(); } main(); ``` --- title: "Wrap the Lifecycle" description: "Turn the script we've been growing into a proper reusable function. Accept a repo URL as input, wrap the body in try/finally so the Sandbox always stops, and return a structured result that the rest of the course can consume." canonical_url: "https://vercel.com/academy/vercel-sandbox/wrap-the-lifecycle" md_url: "https://vercel.com/academy/vercel-sandbox/wrap-the-lifecycle.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-21T16:55:06.986Z" content_type: "lesson" course: "vercel-sandbox" course_title: "Vercel Sandbox" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Wrap the Lifecycle # Wrap the Full Lifecycle in a Function Every working script eventually wants to be a function. Ours is no exception. Sandboxes stop automatically when their configured timeout expires; the default session timeout is five minutes. Prompt cleanup still matters because idle time consumes resources until that safeguard runs. Before we hand this off to a CLI, we'll wrap the lifecycle in `try/finally` so `stop()` runs as soon as the work finishes or throws. ## Outcome Refactor the script into an exported `runSandboxLifecycle(repoUrl)` function that wraps create → clone → read in `try/finally`, always calls `stop()`, and returns a structured result. ## Fast Track 1. Extract the body into `export async function runSandboxLifecycle(repoUrl: string)`. 2. Wrap the work in `try/finally` with `sandbox.stop()` in `finally`. 3. Return `{ sandboxName, cloneExitCode, files, readmePreview }`. ## Hands-on exercise Restructure `src/sandbox-lifecycle.ts`. We're keeping all the logic from the last lesson, just reorganizing it. ```ts import { Sandbox } from '@vercel/sandbox'; export type LifecycleResult = { sandboxName: string; cloneExitCode: number; files: string; readmePreview: string; }; export async function runSandboxLifecycle(repoUrl: string): Promise { const sandbox = await Sandbox.create({ persistent: false, timeout: 10 * 60 * 1000 }); try { const clone = await sandbox.runCommand('git', ['clone', '--depth', '1', repoUrl, 'repo']); if (clone.exitCode !== 0) { throw new Error(`Clone failed: ${await clone.stderr()}`); } const ls = await sandbox.runCommand('ls', ['-la', 'repo']); let readmePreview = '(no README found)'; const readme = await sandbox.readFileToBuffer({ path: 'repo/README.md' }); if (readme) { readmePreview = readme.toString('utf8').slice(0, 300); } return { sandboxName: sandbox.name, cloneExitCode: clone.exitCode, files: await ls.stdout(), readmePreview }; } finally { await sandbox.stop(); } } ``` Two changes worth pointing out. First, a failed clone now `throw`s instead of returning early. That lets the caller decide how to handle it, and `finally` still cleans up the Sandbox either way. Second, we removed the `console.log` calls. Logging is the CLI's job, not the lifecycle's. To verify the function still works end-to-end, add a small test caller below the function (we'll delete this when the CLI takes over): ```ts async function main() { const result = await runSandboxLifecycle('https://github.com/vercel/examples'); console.log(result); } main(); ``` \*\*Warning: Troubleshooting: forgot to await stop\*\* If the script exits before `sandbox.stop()` finishes, you might leak Sandboxes. `await` is doing real work here, not just type comfort. \*\*Note: Troubleshooting: should clone failures throw?\*\* Throwing makes failed clones look the same as unexpected crashes to the caller. If you'd rather distinguish them, return `cloneExitCode` and a `success: boolean` field instead. For this course we're keeping it simple. ## Try It ```bash pnpm tsx src/sandbox-lifecycle.ts ``` Expected output: ```txt { sandboxName: 'repo-review-...', cloneExitCode: 0, files: 'total 32\ndrwxr-xr-x ... README.md\n...', readmePreview: '# Vercel Examples\n\nThis repository contains...' } ``` One object, ready to be consumed by something else. That something else is the CLI we build next. ## Commit ```bash git add src/sandbox-lifecycle.ts git commit -m "feat(sandbox): wrap lifecycle in a reusable function with try/finally" ``` ## Done-When - [ ] `runSandboxLifecycle(repoUrl)` is exported and accepts a URL - [ ] Body is wrapped in `try/finally` - [ ] `sandbox.stop()` runs in `finally` even on throw - [ ] Returns `{ sandboxName, cloneExitCode, files, readmePreview }` ## Solution ```ts title="src/sandbox-lifecycle.ts" import { Sandbox } from '@vercel/sandbox'; export type LifecycleResult = { sandboxName: string; cloneExitCode: number; files: string; readmePreview: string; }; export async function runSandboxLifecycle(repoUrl: string): Promise { const sandbox = await Sandbox.create({ persistent: false, timeout: 10 * 60 * 1000 }); try { const clone = await sandbox.runCommand('git', ['clone', '--depth', '1', repoUrl, 'repo']); if (clone.exitCode !== 0) { throw new Error(`Clone failed: ${await clone.stderr()}`); } const ls = await sandbox.runCommand('ls', ['-la', 'repo']); let readmePreview = '(no README found)'; const readme = await sandbox.readFileToBuffer({ path: 'repo/README.md' }); if (readme) { readmePreview = readme.toString('utf8').slice(0, 300); } return { sandboxName: sandbox.name, cloneExitCode: clone.exitCode, files: await ls.stdout(), readmePreview }; } finally { await sandbox.stop(); } } ``` --- title: "Scaffold the CLI" description: "Set up commander, register a `review ` command, and parse the argument so we have somewhere to wire the Sandbox lifecycle in the next lessons. Just the skeleton, no Sandbox calls yet." canonical_url: "https://vercel.com/academy/vercel-sandbox/scaffold-the-cli" md_url: "https://vercel.com/academy/vercel-sandbox/scaffold-the-cli.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-21T16:55:07.063Z" content_type: "lesson" course: "vercel-sandbox" course_title: "Vercel Sandbox" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Scaffold the CLI # Scaffold the `repo-review` CLI A reusable function is great. Nobody's typing `pnpm tsx src/sandbox-lifecycle.ts https://github.com/...` every day, though. We need a command-line surface. Something a person can run from a terminal, something a CI job can call, something with a name. We'll use `commander` because it handles the boring parts (parsing args, generating help text, exit behavior) and we'd rather not. ## Outcome Create `src/cli.ts` with a `review ` command using `commander`. The command parses its argument and prints what it received. No Sandbox work yet. ## Fast Track 1. Add `commander` to the project. 2. Create `src/cli.ts` and import `Command`. 3. Register a `review ` command whose action logs the URL. ## Hands-on exercise `commander` is already in the starter's `package.json`, so no install needed. If you were spinning this up from scratch, you'd run: ```bash pnpm add commander ``` Create `src/cli.ts`: ```ts import { Command } from 'commander'; const program = new Command(); program .name('repo-review') .description('Clone and review a GitHub repository in a Sandbox') .version('0.1.0'); program .command('review ') .description('Run a Sandbox review against a GitHub repository URL') .action(async (repoUrl: string) => { console.log(`Would review: ${repoUrl}`); }); await program.parseAsync(); ``` That's the whole skeleton. The `action` callback is where the Sandbox lifecycle will live in lesson 2.3, but right now we're just confirming the plumbing works. If you run it with a URL and see the URL echoed back, the CLI surface is in place. Add a script to your `package.json` so we don't have to type the long form every time: ```json title="package.json" { "scripts": { "review": "tsx src/cli.ts review" } } ``` Now `pnpm review ` runs your CLI. \*\*Warning: Troubleshooting: command not found\*\* If `pnpm review` reports "command not found", check that the script is in your `package.json` and that `tsx` is installed (`pnpm add -D tsx` if not). The `commander` part of "command not found" is misleading; the error usually comes from missing scripts, not missing commander. \*\*Note: Troubleshooting: help text empty\*\* `program.parseAsync()` reads from `process.argv` and waits for async action handlers. If you run `pnpm review` with no args, commander prints "missing required argument 'repoUrl'", not your description. That's expected behavior, not a bug. ## Try It ```bash pnpm review https://github.com/vercel/examples ``` Expected output: ```txt Would review: https://github.com/vercel/examples ``` And without an argument, you should see commander's built-in error: ```bash pnpm review ``` ```txt error: missing required argument 'repoUrl' ``` Both behaviors are good. The first proves the action runs; the second proves commander is enforcing the argument shape for us. ## Commit ```bash git add src/cli.ts package.json git commit -m "feat(cli): scaffold review command with commander" ``` ## Done-When - [ ] `commander` is installed - [ ] `src/cli.ts` registers a `review ` command - [ ] `pnpm review ` echoes the URL back - [ ] `pnpm review` (no args) shows commander's missing-argument error ## Solution ```ts title="src/cli.ts" import { Command } from 'commander'; const program = new Command(); program .name('repo-review') .description('Clone and review a GitHub repository in a Sandbox') .version('0.1.0'); program .command('review ') .description('Run a Sandbox review against a GitHub repository URL') .action(async (repoUrl: string) => { console.log(`Would review: ${repoUrl}`); }); await program.parseAsync(); ``` ```json title="package.json (scripts section)" { "scripts": { "review": "tsx src/cli.ts review" } } ``` --- title: "Validate GitHub URLs" description: "Add input validation to the CLI so we never spin up a Sandbox for a URL that isn't a real GitHub repo. A small regex check upfront saves a lot of confused failures downstream." canonical_url: "https://vercel.com/academy/vercel-sandbox/validate-github-urls" md_url: "https://vercel.com/academy/vercel-sandbox/validate-github-urls.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-21T16:55:07.092Z" content_type: "lesson" course: "vercel-sandbox" course_title: "Vercel Sandbox" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Validate GitHub URLs # Validate GitHub URLs Before Spending Money A CLI without input validation is a vending machine that accepts expired coupons, parking tickets, and emotional support receipts. It still tries to do the work, then fails in weird ways. Worse, in our case "tries to do the work" means spinning up a Sandbox. That costs real time and real money. We'd rather catch a typo before we boot a microVM. ## Outcome Add a URL validator to the CLI that rejects anything that isn't a GitHub repo URL, and exit early when validation fails. ## Fast Track 1. Write `isValidGitHubRepoUrl(input)` that returns a boolean. 2. Call it at the top of the `action` callback. 3. On failure, log a clear error and `return` before any Sandbox call. ## Hands-on exercise Open `src/cli.ts` and add a validator above the program setup: ```ts import { Command } from 'commander'; function isValidGitHubRepoUrl(input: string): boolean { return /^https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/?$/.test(input); } const program = new Command(); program .name('repo-review') .description('Clone and review a GitHub repository in a Sandbox') .version('0.1.0'); program .command('review ') .description('Run a Sandbox review against a GitHub repository URL') .action(async (repoUrl: string) => { if (!isValidGitHubRepoUrl(repoUrl)) { console.error(`Invalid GitHub repository URL: ${repoUrl}`); console.error('Expected format: https://github.com//'); return; } console.log(`Would review: ${repoUrl}`); }); await program.parseAsync(); ``` The regex is intentionally narrow. It matches the standard GitHub web URL (`https://github.com/owner/repo` with an optional trailing slash) and nothing else. We're not trying to support every Git URL format on earth, we're trying to keep junk out of our pipeline. We're also returning early instead of throwing. Throwing would crash the Node process with an unhandled rejection, which is loud but not helpful. We'll wire up real exit codes in lesson 2.4. For now, "log and return" is enough. \*\*Warning: Troubleshooting: valid URLs being rejected\*\* If a real repo URL fails validation, try it with and without a trailing slash. The regex allows both, but typos like `https://github.com//owner/repo` (double slash) or `http://` (no `s`) will be rejected. That's the regex doing its job. \*\*Note: Troubleshooting: pattern too strict\*\* If you want to support GitHub Enterprise URLs (`https://github.mycompany.com/...`), you'll need a different regex. For this course, public GitHub only. ## Try It Run an invalid URL: ```bash pnpm review not-a-url ``` Expected output: ```txt Invalid GitHub repository URL: not-a-url Expected format: https://github.com// ``` Then a valid one: ```bash pnpm review https://github.com/vercel/examples ``` Expected output: ```txt Would review: https://github.com/vercel/examples ``` Same valid output as last lesson, plus a guardrail at the front door. ## Commit ```bash git add src/cli.ts git commit -m "feat(cli): validate GitHub repository URLs before sandbox work" ``` ## Done-When - [ ] `isValidGitHubRepoUrl` returns `true` for `https://github.com/owner/repo` (with or without trailing slash) - [ ] `isValidGitHubRepoUrl` returns `false` for plain strings, non-GitHub hosts, and malformed URLs - [ ] Invalid URLs print a clear error message - [ ] Valid URLs reach the `console.log` line ## Solution ```ts title="src/cli.ts" import { Command } from 'commander'; function isValidGitHubRepoUrl(input: string): boolean { return /^https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/?$/.test(input); } const program = new Command(); program .name('repo-review') .description('Clone and review a GitHub repository in a Sandbox') .version('0.1.0'); program .command('review ') .description('Run a Sandbox review against a GitHub repository URL') .action(async (repoUrl: string) => { if (!isValidGitHubRepoUrl(repoUrl)) { console.error(`Invalid GitHub repository URL: ${repoUrl}`); console.error('Expected format: https://github.com//'); return; } console.log(`Would review: ${repoUrl}`); }); await program.parseAsync(); ``` --- title: "Wire the Sandbox Workflow" description: "Replace the \"would review\" placeholder with the real thing. Import `runSandboxLifecycle` from Chapter 1, call it after the URL passes validation, and print the structured result." canonical_url: "https://vercel.com/academy/vercel-sandbox/wire-the-sandbox-workflow" md_url: "https://vercel.com/academy/vercel-sandbox/wire-the-sandbox-workflow.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-21T16:55:07.124Z" content_type: "lesson" course: "vercel-sandbox" course_title: "Vercel Sandbox" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Wire the Sandbox Workflow # Wire the Sandbox Workflow into the CLI We have two pieces. A lifecycle function that does the work, and a CLI that knows when to do it. Time to connect them. This is one of those lessons where the code change is small but the moment is big. The first time you run `pnpm review ` and watch it boot a Sandbox, clone the repo, and print back a structured result is when the project stops feeling like a tutorial and starts feeling like a tool. ## Outcome Replace the `console.log` placeholder in the `action` callback with a real call to `runSandboxLifecycle`, and print the returned result. ## Fast Track 1. Import `runSandboxLifecycle` from `./sandbox-lifecycle`. 2. Call it inside the validated `action` callback. 3. Log the structured result. ## Hands-on exercise Open `src/cli.ts` and add the import: ```ts import { Command } from 'commander'; import { runSandboxLifecycle } from './sandbox-lifecycle'; function isValidGitHubRepoUrl(input: string): boolean { return /^https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/?$/.test(input); } const program = new Command(); program .name('repo-review') .description('Clone and review a GitHub repository in a Sandbox') .version('0.1.0'); program .command('review ') .description('Run a Sandbox review against a GitHub repository URL') .action(async (repoUrl: string) => { if (!isValidGitHubRepoUrl(repoUrl)) { console.error(`Invalid GitHub repository URL: ${repoUrl}`); console.error('Expected format: https://github.com//'); return; } console.log(`Reviewing ${repoUrl}...`); const result = await runSandboxLifecycle(repoUrl); console.log(`Sandbox: ${result.sandboxName}`); console.log(`Clone exit code: ${result.cloneExitCode}`); console.log(`Files:\n${result.files}`); console.log(`README preview:\n${result.readmePreview}`); }); await program.parseAsync(); ``` Notice we're not catching the error from `runSandboxLifecycle` yet. If the clone fails, the function throws, and we'll see an unhandled rejection in the terminal. We'll fix that in the next lesson when we add real exit-code handling. For now, the happy path is the goal. Also: since `runSandboxLifecycle` is no longer being called from the bottom of `src/sandbox-lifecycle.ts`, you can delete the temporary `main()` test caller we added in lesson 1.4. The CLI is the real caller now. \*\*Warning: Troubleshooting: import path\*\* TypeScript imports omit the `.ts` extension (`./sandbox-lifecycle`, not `./sandbox-lifecycle.ts`). If you see "cannot find module", drop the extension. \*\*Note: Troubleshooting: output too noisy\*\* Logging the full `ls -la` output is verbose. That's fine for now since we're confirming the wire is connected. We'll trim what gets printed when we add the proper reporter in Chapter 5. ## Try It ```bash pnpm review https://github.com/vercel/examples ``` Expected output: ```txt Reviewing https://github.com/vercel/examples... Sandbox: repo-review-... Clone exit code: 0 Files: total 32 drwxr-xr-x ... README.md drwxr-xr-x ... examples README preview: # Vercel Examples This repository contains... ``` That's the whole first half of the course in one CLI invocation. Validation, Sandbox boot, clone, file inspection, clean shutdown. ## Commit ```bash git add src/cli.ts src/sandbox-lifecycle.ts git commit -m "feat(cli): wire sandbox lifecycle into the review command" ``` ## Done-When - [ ] CLI imports `runSandboxLifecycle` from `./sandbox-lifecycle` - [ ] Valid URL triggers a real Sandbox run - [ ] Invalid URL still exits early without booting a Sandbox - [ ] Result fields (`sandboxName`, `cloneExitCode`, `files`, `readmePreview`) all print ## Solution ```ts title="src/cli.ts" import { Command } from 'commander'; import { runSandboxLifecycle } from './sandbox-lifecycle'; function isValidGitHubRepoUrl(input: string): boolean { return /^https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/?$/.test(input); } const program = new Command(); program .name('repo-review') .description('Clone and review a GitHub repository in a Sandbox') .version('0.1.0'); program .command('review ') .description('Run a Sandbox review against a GitHub repository URL') .action(async (repoUrl: string) => { if (!isValidGitHubRepoUrl(repoUrl)) { console.error(`Invalid GitHub repository URL: ${repoUrl}`); console.error('Expected format: https://github.com//'); return; } console.log(`Reviewing ${repoUrl}...`); const result = await runSandboxLifecycle(repoUrl); console.log(`Sandbox: ${result.sandboxName}`); console.log(`Clone exit code: ${result.cloneExitCode}`); console.log(`Files:\n${result.files}`); console.log(`README preview:\n${result.readmePreview}`); }); await program.parseAsync(); ``` --- title: "Predictable Exit Codes" description: "Wrap the lifecycle call in `try/catch`, set `process.exitCode` for each failure mode, and standardize on 0 / 1 / 2 so CI jobs can fail on the right things and pass on the right things." canonical_url: "https://vercel.com/academy/vercel-sandbox/predictable-exit-codes" md_url: "https://vercel.com/academy/vercel-sandbox/predictable-exit-codes.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-21T16:55:07.152Z" content_type: "lesson" course: "vercel-sandbox" course_title: "Vercel Sandbox" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Predictable Exit Codes # Exit Codes That CI Can Read A CLI that always exits 0 is a CLI that always passes in CI. Even when it didn't. This is one of those small things that bites you exactly once, on a Friday afternoon, when a "successful" review run shipped broken code to production. We're going to fix it now. Three exit codes, one rule each: - **0** — the review actually ran and the Sandbox came back - **1** — the review tried to run but something inside it threw - **2** — the user gave us bad input (we never even started) ## Outcome Wrap the lifecycle call in `try/catch`, set `process.exitCode` for each failure mode, and standardize on 0 / 1 / 2 across the CLI. ## Fast Track 1. Set `process.exitCode = 2` on validation failure. 2. Wrap `runSandboxLifecycle` in `try/catch`. 3. Set `process.exitCode = 1` in the catch block. ## Hands-on exercise Open `src/cli.ts` and add the exit-code handling: ```ts import { Command } from 'commander'; import { runSandboxLifecycle } from './sandbox-lifecycle'; function isValidGitHubRepoUrl(input: string): boolean { return /^https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/?$/.test(input); } const program = new Command(); program .name('repo-review') .description('Clone and review a GitHub repository in a Sandbox') .version('0.1.0'); program .command('review ') .description('Run a Sandbox review against a GitHub repository URL') .action(async (repoUrl: string) => { if (!isValidGitHubRepoUrl(repoUrl)) { console.error(`Invalid GitHub repository URL: ${repoUrl}`); console.error('Expected format: https://github.com//'); process.exitCode = 2; return; } console.log(`Reviewing ${repoUrl}...`); try { const result = await runSandboxLifecycle(repoUrl); console.log(`Sandbox: ${result.sandboxName}`); console.log(`Clone exit code: ${result.cloneExitCode}`); console.log(`Files:\n${result.files}`); console.log(`README preview:\n${result.readmePreview}`); } catch (error) { console.error('Review failed:', error instanceof Error ? error.message : error); process.exitCode = 1; } }); await program.parseAsync(); ``` A few intentional details here. We're using `process.exitCode` instead of `process.exit(N)`. `process.exit` kills the process immediately, which means any pending async work (like a `sandbox.stop()` that hasn't finished) gets cut off. `process.exitCode` sets the value Node uses when it exits naturally, which gives the lifecycle's `finally` block time to clean up. We're also formatting the error message instead of dumping the whole `Error` object. CI logs are easier to read when the first line is the message, not a stack trace. The stack will still print to stderr from Node's default behavior if the throw is unhandled, but our handled case keeps it tidy. \*\*Warning: Troubleshooting: still exits 0 on failure\*\* If `pnpm review ` is still exiting 0, check that you set `process.exitCode` and not just `console.error`'d. They're independent. \*\*Note: Troubleshooting: why not throw?\*\* Throwing in the action callback causes commander to print an unhandled rejection and exit with code 1, which sort of works. But it logs noise that CI tools then have to filter out. `try/catch` + `process.exitCode` is cleaner. ## Try It Run all three cases and check exit codes after each: ```bash pnpm review https://github.com/vercel/examples echo "Exit: $?" ``` Expected: ```txt Reviewing https://github.com/vercel/examples... Sandbox: repo-review-... ... (success output) Exit: 0 ``` Then an invalid URL: ```bash pnpm review not-a-url echo "Exit: $?" ``` ```txt Invalid GitHub repository URL: not-a-url Expected format: https://github.com// Exit: 2 ``` Then a repo that doesn't exist (the clone will fail and throw inside the lifecycle): ```bash pnpm review https://github.com/this-does-not-exist/nope echo "Exit: $?" ``` ```txt Reviewing https://github.com/this-does-not-exist/nope... Review failed: Clone failed: fatal: repository '...' not found Exit: 1 ``` Three different problems, three different exit codes. CI can finally tell them apart. ## Commit ```bash git add src/cli.ts git commit -m "feat(cli): standardize exit codes for input vs runtime failures" ``` ## Done-When - [ ] Successful review exits 0 - [ ] Invalid URL exits 2 without booting a Sandbox - [ ] Runtime failure (failed clone) exits 1 - [ ] `process.exit(...)` is not used anywhere (we use `process.exitCode`) ## Solution ```ts title="src/cli.ts" import { Command } from 'commander'; import { runSandboxLifecycle } from './sandbox-lifecycle'; function isValidGitHubRepoUrl(input: string): boolean { return /^https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/?$/.test(input); } const program = new Command(); program .name('repo-review') .description('Clone and review a GitHub repository in a Sandbox') .version('0.1.0'); program .command('review ') .description('Run a Sandbox review against a GitHub repository URL') .action(async (repoUrl: string) => { if (!isValidGitHubRepoUrl(repoUrl)) { console.error(`Invalid GitHub repository URL: ${repoUrl}`); console.error('Expected format: https://github.com//'); process.exitCode = 2; return; } console.log(`Reviewing ${repoUrl}...`); try { const result = await runSandboxLifecycle(repoUrl); console.log(`Sandbox: ${result.sandboxName}`); console.log(`Clone exit code: ${result.cloneExitCode}`); console.log(`Files:\n${result.files}`); console.log(`README preview:\n${result.readmePreview}`); } catch (error) { console.error('Review failed:', error instanceof Error ? error.message : error); process.exitCode = 1; } }); await program.parseAsync(); ``` --- title: "The Naive Prompt" description: "Start with the obvious \"review this code and tell me what's wrong\" prompt and run it against a real file. We're going to feel the problem before we fix it, because that's how the schema-driven version earns its keep in lesson 3.3." canonical_url: "https://vercel.com/academy/vercel-sandbox/the-naive-prompt" md_url: "https://vercel.com/academy/vercel-sandbox/the-naive-prompt.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-21T16:55:07.207Z" content_type: "lesson" course: "vercel-sandbox" course_title: "Vercel Sandbox" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # The Naive Prompt # Write the Wrong Prompt on Purpose The first AI review prompt usually reads like a text from a sleep-deprived intern, "look at this code and tell me if it is bad." Then the model replies with advice so generic it could apply to a toaster. We're going to write exactly that prompt, run it, and look at what comes back. Not because it's the answer, but because it's the baseline. The structured-output version we build in 3.3 is going to feel like magic by comparison, and that comparison only lands if you've seen the bad version first. ## Outcome Create `src/analyze.ts` with an `analyzeWithPromptV1` function that uses AI SDK v6's `generateText` and a deliberately vague prompt. Run it against a real file and read the (probably underwhelming) output. ## Fast Track 1. Install AI SDK v6 (`ai`) and load your API key. 2. Create `src/analyze.ts` with `analyzeWithPromptV1(source)` that calls `generateText`. 3. Call it from a quick test script with a real source file and print the result. ## Hands-on exercise The AI SDK (`ai`) is already in the starter's `package.json`. From scratch, you'd install it with: ```bash pnpm add ai ``` Create `src/analyze.ts`: ```ts import { generateText } from 'ai'; export async function analyzeWithPromptV1(source: string): Promise { const result = await generateText({ model: 'openai/gpt-5.3-codex', prompt: `Review this code and tell me what is wrong:\n\n${source}` }); return result.text; } ``` That's the whole "review" function. One prompt string, one model call, one text response. It will run. It will return something. The something will not be very useful. Add a quick test script at the bottom of the file so you can run this without wiring it into the CLI yet: ```ts async function main() { const source = ` export function login(user: string, password: string) { if (password === 'admin') return true; return false; } `; const review = await analyzeWithPromptV1(source); console.log(review); } main(); ``` We're feeding it an obviously bad piece of code (hardcoded "admin" password, no validation, no hashing) so the failure mode is easy to see. \*\*Warning: Troubleshooting: missing API key\*\* AI Gateway reads `AI_GATEWAY_API_KEY` or a Vercel OIDC token. For local development, run `vercel link` and `vercel env pull .env`, then load that env file when you run the script. `OPENAI_API_KEY` is a direct-provider credential, not the default AI Gateway credential. \*\*Note: Troubleshooting: model not available\*\* If `openai/gpt-5.3-codex` returns a "model not found" error, check the current AI Gateway model catalog and choose a coding model available to your account. The point isn't the specific model, it's the prompt shape. ## Try It ```bash pnpm tsx src/analyze.ts ``` Expected output (the exact text varies by run, but the shape is consistent): ```txt This code has several issues to consider: 1. The password "admin" is hardcoded, which is a security concern. 2. There's no input validation on the user parameter. 3. The function doesn't use proper authentication patterns. 4. Consider adding error handling. 5. You may want to add types for better TypeScript usage. ``` Read that output. Then ask: - Is "consider adding error handling" actionable? What error? - Is "doesn't use proper authentication patterns" specific? Which patterns? - Could we feed this output to another tool? It's a wall of prose. - Would we want to compare two reviews? They'd be impossible to diff. The model isn't wrong, it's just vague. That's what happens when we hand it an open-ended task and a vague prompt. In the next lesson we design a schema that forces the model to be specific. ## Commit ```bash git add src/analyze.ts git commit -m "feat(analyze): add naive v1 prompt as a baseline" ``` ## Done-When - [ ] `ai` package available (already in the starter) - [ ] `src/analyze.ts` exports `analyzeWithPromptV1` - [ ] Running it produces some response (anything, even bad) - [ ] You read the response and noticed how vague it is ## Solution ```ts title="src/analyze.ts" import { generateText } from 'ai'; export async function analyzeWithPromptV1(source: string): Promise { const result = await generateText({ model: 'openai/gpt-5.3-codex', prompt: `Review this code and tell me what is wrong:\n\n${source}` }); return result.text; } ``` --- title: "Design the Review Schema" description: "A schema is a contract the model has to keep. In this lesson, we define Zod schemas for a single finding (severity, category, file, summary, recommendation) and for the overall review (risk level + findings array) so the next lesson can demand exactly that shape from the model." canonical_url: "https://vercel.com/academy/vercel-sandbox/design-the-review-schema" md_url: "https://vercel.com/academy/vercel-sandbox/design-the-review-schema.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-21T16:55:07.244Z" content_type: "lesson" course: "vercel-sandbox" course_title: "Vercel Sandbox" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Design the Review Schema # Design the Review Schema The fix for vague AI output isn't a better prompt. It's a contract. When we hand the model a Zod schema, we're saying "you can return whatever you want, as long as it has these fields, with these types, with these allowed values." The model still has full creative license, just inside a box we built. And the box is what makes the output useful, comparable, and easy to feed into the next thing. ## Outcome Define two Zod schemas in `src/analyze.ts`: one for an individual `Finding`, and one for the overall `Review` that wraps an array of findings. ## Fast Track 1. Install `zod`. 2. Define `findingSchema` with enums for `severity` and `category`. 3. Define `reviewSchema` with an `overallRisk` enum and `findings` array. ## Hands-on exercise `zod` is already in the starter's `package.json`. From scratch, you'd run: ```bash pnpm add zod ``` Add the schemas to `src/analyze.ts`. We're keeping `analyzeWithPromptV1` around (we'll delete the test caller in 3.4) and adding the schema definitions above it: ```ts import { generateText } from 'ai'; import { z } from 'zod'; export const findingSchema = z.object({ severity: z.enum(['low', 'medium', 'high', 'critical']), category: z.enum(['security', 'quality', 'performance', 'reliability']), file: z.string(), summary: z.string(), recommendation: z.string() }); export const reviewSchema = z.object({ overallRisk: z.enum(['low', 'medium', 'high']), findings: z.array(findingSchema) }); export type Finding = z.infer; export type Review = z.infer; // existing analyzeWithPromptV1 stays below export async function analyzeWithPromptV1(source: string): Promise { const result = await generateText({ model: 'openai/gpt-5.3-codex', prompt: `Review this code and tell me what is wrong:\n\n${source}` }); return result.text; } ``` A few design choices worth flagging. Severity has four levels because three felt too coarse and five felt like overthinking it. Critical → high → medium → low maps to how a human would skim the report. Category has only four values on purpose. Limiting categories prevents the model from inventing new ones ("aesthetic", "philosophical") that don't help anyone triage. Adding a category later is easy; removing one is awkward. `overallRisk` has three levels (low/medium/high), one fewer than per-finding severity. A single critical finding in an otherwise clean repo isn't a "critical" overall risk, it's a high one. The asymmetry is intentional. We're also exporting both schemas and the inferred TypeScript types. Both will be used in the next lesson. \*\*Warning: Troubleshooting: TS errors on z.infer\*\* `z.infer` works with exported or local schemas. We export these schemas because later lessons import them. If TypeScript reports an unused symbol, check that the corresponding schema or type is actually referenced. \*\*Note: Troubleshooting: tempted to add more enums\*\* Resist. Every additional enum value is another thing the model has to learn when to use. Start narrow, add fields only when you've seen real review output that needed them. ## Try It There's nothing runnable yet (`generateObject` comes in 3.3), so we'll just typecheck: ```bash pnpm tsc --noEmit ``` Expected: no errors. If you want to confirm the schemas parse correctly, drop this temporarily into the bottom of `src/analyze.ts` and run it: ```ts const sample: Review = { overallRisk: 'medium', findings: [ { severity: 'high', category: 'security', file: 'src/auth.ts', summary: 'Hardcoded password', recommendation: 'Use bcrypt and an env var' } ] }; console.log(reviewSchema.parse(sample)); ``` Expected output: ```txt { overallRisk: 'medium', findings: [ { severity: 'high', category: 'security', file: 'src/auth.ts', summary: 'Hardcoded password', recommendation: 'Use bcrypt and an env var' } ] } ``` If the parse throws, the schema disagrees with the data. Try changing `severity: 'high'` to `severity: 'extreme'` to watch the validation fail. Delete the temporary sample before moving on. ## Commit ```bash git add src/analyze.ts git commit -m "feat(analyze): define findings and review zod schemas" ``` ## Done-When - [ ] `zod` is available (already in the starter) - [ ] `findingSchema` and `reviewSchema` are defined and exported - [ ] `Finding` and `Review` types are exported via `z.infer` - [ ] `pnpm tsc --noEmit` passes ## Solution ```ts title="src/analyze.ts" import { generateText } from 'ai'; import { z } from 'zod'; export const findingSchema = z.object({ severity: z.enum(['low', 'medium', 'high', 'critical']), category: z.enum(['security', 'quality', 'performance', 'reliability']), file: z.string(), summary: z.string(), recommendation: z.string() }); export const reviewSchema = z.object({ overallRisk: z.enum(['low', 'medium', 'high']), findings: z.array(findingSchema) }); export type Finding = z.infer; export type Review = z.infer; export async function analyzeWithPromptV1(source: string): Promise { const result = await generateText({ model: 'openai/gpt-5.3-codex', prompt: `Review this code and tell me what is wrong:\n\n${source}` }); return result.text; } ``` --- title: "Generate Structured Reviews" description: "Swap `generateText` for `generateObject`, pass the schema we built in 3.2, and watch the model return a typed object instead of a wall of advice. Same model, same source, dramatically more useful output." canonical_url: "https://vercel.com/academy/vercel-sandbox/generate-structured-reviews" md_url: "https://vercel.com/academy/vercel-sandbox/generate-structured-reviews.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-21T16:55:07.271Z" content_type: "lesson" course: "vercel-sandbox" course_title: "Vercel Sandbox" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Generate Structured Reviews # Generate Structured Reviews Same model. Same code under review. New output shape. In 3.1 we got back a paragraph. In this lesson we get back a typed object with severity levels, file paths, and concrete recommendations. The only thing that changed is which function we called and what we handed it. ## Outcome Add `analyzeRepository(files)` to `src/analyze.ts`. It uses `generateObject` with the `reviewSchema` from 3.2, takes an array of `{ path, content }` files, and returns a typed `Review`. ## Fast Track 1. Import `generateObject` from `ai`. 2. Write `analyzeRepository(files)` that builds a prompt from the files and calls `generateObject({ schema, prompt, model })`. 3. Return `result.object` (typed as `Review`). ## Hands-on exercise Open `src/analyze.ts` and add the new function. The schemas and `analyzeWithPromptV1` stay where they are: ```ts import { generateObject, generateText } from 'ai'; import { z } from 'zod'; export const findingSchema = z.object({ severity: z.enum(['low', 'medium', 'high', 'critical']), category: z.enum(['security', 'quality', 'performance', 'reliability']), file: z.string(), summary: z.string(), recommendation: z.string() }); export const reviewSchema = z.object({ overallRisk: z.enum(['low', 'medium', 'high']), findings: z.array(findingSchema) }); export type Finding = z.infer; export type Review = z.infer; export async function analyzeWithPromptV1(source: string): Promise { const result = await generateText({ model: 'openai/gpt-5.3-codex', prompt: `Review this code and tell me what is wrong:\n\n${source}` }); return result.text; } export async function analyzeRepository( files: Array<{ path: string; content: string }> ): Promise { const prompt = [ 'You are a senior application security and code quality reviewer.', 'Return only findings that are directly supported by the provided source.', 'Prefer precise, actionable recommendations over generic advice.', 'If there are no findings, return an empty findings array.', '', ...files.map((f) => `FILE: ${f.path}\n${f.content}`) ].join('\n'); const result = await generateObject({ model: 'openai/gpt-5.3-codex', schema: reviewSchema, prompt }); return result.object; } ``` Two things to point out. The prompt is doing work the schema can't. The schema enforces the *shape* of the output, but it can't tell the model "be specific" or "don't make things up." That's what the system-style preamble is for. We're telling the model who it is, what to return, and what not to return. Those three sentences raise the quality of findings by a lot. The file format inside the prompt (`FILE: path\ncontent\n`) is intentionally plain. We're not using JSON or YAML or anything fancy. Models are good at reading "FILE: x" headers because they look like a lot of the training data. To verify, replace the temporary `main()` test caller at the bottom of `src/analyze.ts` with one that calls the new function: ```ts async function main() { const files = [ { path: 'src/auth.ts', content: ` export function login(user: string, password: string) { if (password === 'admin') return true; return false; } ` } ]; const review = await analyzeRepository(files); console.log(JSON.stringify(review, null, 2)); } main(); ``` \*\*Warning: Troubleshooting: validation error from generateObject\*\* If `generateObject` throws a schema validation error, the model returned something that didn't match the shape. Usually this means tightening the prompt ("only use the listed categories") or loosening the schema (allow more enum values). \*\*Note: AI SDK version note\*\* This course targets AI SDK v6, where `generateObject` is available. Current AI SDK releases prefer `generateText` with `Output.object()` for new code. Follow the migration guide if your project uses that newer API rather than mixing examples across versions. \*\*Warning: Repository text is untrusted model input\*\* A source file can contain prompt-injection text aimed at the reviewer. Treat model findings as claims to verify, never as instructions to execute, and keep repository content out of system-level instructions and credentials. \*\*Note: Troubleshooting: empty findings array\*\* An empty array is a valid response. If the code you're reviewing actually has no issues, `findings: []` is what we want. Don't read it as a bug. ## Try It ```bash pnpm tsx src/analyze.ts ``` Expected output (specific findings will vary, but the shape is fixed): ```json { "overallRisk": "high", "findings": [ { "severity": "critical", "category": "security", "file": "src/auth.ts", "summary": "Hardcoded admin password in login function", "recommendation": "Replace the hardcoded check with a lookup against a securely hashed password store (bcrypt or argon2) and load the comparison value from environment configuration." }, { "severity": "high", "category": "quality", "file": "src/auth.ts", "summary": "Function returns boolean instead of a typed user record", "recommendation": "Return a discriminated union like { ok: true, user } | { ok: false, reason } so callers can react to specific failure modes." } ] } ``` Put that next to the prose blob from 3.1 and the difference is obvious. Each finding has a severity you can sort by, a file you can jump to, a recommendation specific enough to act on. The schema did that. ## Commit ```bash git add src/analyze.ts git commit -m "feat(analyze): generate structured reviews with generateObject" ``` ## Done-When - [ ] `analyzeRepository(files)` is exported from `src/analyze.ts` - [ ] It uses `generateObject` with `reviewSchema` - [ ] Output is a typed `Review` object - [ ] Running against a known-bad file produces concrete, file-anchored findings ## Solution ```ts title="src/analyze.ts" import { generateObject, generateText } from 'ai'; import { z } from 'zod'; export const findingSchema = z.object({ severity: z.enum(['low', 'medium', 'high', 'critical']), category: z.enum(['security', 'quality', 'performance', 'reliability']), file: z.string(), summary: z.string(), recommendation: z.string() }); export const reviewSchema = z.object({ overallRisk: z.enum(['low', 'medium', 'high']), findings: z.array(findingSchema) }); export type Finding = z.infer; export type Review = z.infer; export async function analyzeWithPromptV1(source: string): Promise { const result = await generateText({ model: 'openai/gpt-5.3-codex', prompt: `Review this code and tell me what is wrong:\n\n${source}` }); return result.text; } export async function analyzeRepository( files: Array<{ path: string; content: string }> ): Promise { const prompt = [ 'You are a senior application security and code quality reviewer.', 'Return only findings that are directly supported by the provided source.', 'Prefer precise, actionable recommendations over generic advice.', 'If there are no findings, return an empty findings array.', '', ...files.map((f) => `FILE: ${f.path}\n${f.content}`) ].join('\n'); const result = await generateObject({ model: 'openai/gpt-5.3-codex', schema: reviewSchema, prompt }); return result.object; } ``` --- title: "Connect Analysis to the CLI" description: "Extend the lifecycle to collect a curated set of files from the cloned repo, pass them to `analyzeRepository`, and print the resulting findings. This is where the two halves of the course finally talk to each other." canonical_url: "https://vercel.com/academy/vercel-sandbox/connect-analysis-to-the-cli" md_url: "https://vercel.com/academy/vercel-sandbox/connect-analysis-to-the-cli.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-21T16:55:07.294Z" content_type: "lesson" course: "vercel-sandbox" course_title: "Vercel Sandbox" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Connect Analysis to the CLI # Connect Analysis to the CLI Two functioning halves of a tool aren't a tool. They're two halves. In this lesson we bridge them. The lifecycle from Chapter 1 needs to hand the analyzer from Chapter 3 a list of files. The CLI from Chapter 2 needs to print what comes back. None of that is new code; it's wiring. ## Outcome Extend `runSandboxLifecycle` to collect a small set of "files of interest" from the cloned repo. Update the CLI to call `analyzeRepository` with those files and print the findings. ## Fast Track 1. In `src/sandbox-lifecycle.ts`, collect `package.json` and a few source files into `files: Array<{ path, content }>`. 2. Return `files` from the lifecycle alongside the existing result fields. 3. In `src/cli.ts`, call `analyzeRepository(result.files)` after the lifecycle and print the findings. ## Hands-on exercise We're going to limit which files we send to the model. Real repos can have thousands of files; sending all of them blows the context window and costs a fortune. For this course, we'll grab the package manifest plus a few source files. Update `src/sandbox-lifecycle.ts`: ```ts import { Sandbox } from '@vercel/sandbox'; const INTERESTING_PATHS = [ 'repo/package.json', 'repo/src/index.ts', 'repo/src/app.ts', 'repo/lib/auth.ts' ]; export type LifecycleResult = { sandboxName: string; cloneExitCode: number; files: Array<{ path: string; content: string }>; }; export async function runSandboxLifecycle(repoUrl: string): Promise { const sandbox = await Sandbox.create({ persistent: false, timeout: 10 * 60 * 1000 }); try { const clone = await sandbox.runCommand('git', ['clone', '--depth', '1', repoUrl, 'repo']); if (clone.exitCode !== 0) { throw new Error(`Clone failed: ${await clone.stderr()}`); } const files: Array<{ path: string; content: string }> = []; for (const fullPath of INTERESTING_PATHS) { const content = await sandbox.readFileToBuffer({ path: fullPath }); if (content) { files.push({ path: fullPath.replace(/^repo\//, ''), content: content.toString('utf8') }); } } return { sandboxName: sandbox.name, cloneExitCode: clone.exitCode, files }; } finally { await sandbox.stop(); } } ``` A few things shifted. We dropped the `ls` output and the README preview; neither was going into the analyzer. We added a hardcoded list of paths to try, and we silently skip ones that don't exist (most repos won't have all four). The `files` array we return is exactly the shape `analyzeRepository` wants. The hardcoded path list is the simplest thing that works. In a real tool you'd walk the repo, filter by extension, maybe rank by relevance. For this course, four guesses is enough to demonstrate the flow. Now update `src/cli.ts` to call the analyzer: ```ts import { Command } from 'commander'; import { runSandboxLifecycle } from './sandbox-lifecycle'; import { analyzeRepository } from './analyze'; function isValidGitHubRepoUrl(input: string): boolean { return /^https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/?$/.test(input); } const program = new Command(); program .name('repo-review') .description('Clone and review a GitHub repository in a Sandbox') .version('0.1.0'); program .command('review ') .description('Run a Sandbox review against a GitHub repository URL') .action(async (repoUrl: string) => { if (!isValidGitHubRepoUrl(repoUrl)) { console.error(`Invalid GitHub repository URL: ${repoUrl}`); console.error('Expected format: https://github.com//'); process.exitCode = 2; return; } console.log(`Reviewing ${repoUrl}...`); try { const lifecycle = await runSandboxLifecycle(repoUrl); console.log(`Sandbox: ${lifecycle.sandboxName}`); console.log(`Collected ${lifecycle.files.length} file(s) for analysis.`); if (lifecycle.files.length === 0) { console.log('No files matched the interest list; skipping analysis.'); return; } const review = await analyzeRepository(lifecycle.files); console.log(`Overall risk: ${review.overallRisk}`); console.log(`Findings: ${review.findings.length}`); for (const finding of review.findings) { console.log(` [${finding.severity}] ${finding.summary} (${finding.file})`); } } catch (error) { console.error('Review failed:', error instanceof Error ? error.message : error); process.exitCode = 1; } }); await program.parseAsync(); ``` The analyzer call sits inside the same `try/catch` as the lifecycle, so an analysis failure also gets exit code 1. Good. One more piece of cleanup. Now that the CLI is the real caller, the temporary `main()` block at the bottom of `src/analyze.ts` (the one we used to test `analyzeRepository` in 3.3) needs to go. Otherwise it runs every time the CLI imports the file. Delete it: ```ts title="src/analyze.ts (delete this block)" async function main() { const files = [ { path: 'src/auth.ts', content: `...` } ]; const review = await analyzeRepository(files); console.log(JSON.stringify(review, null, 2)); } main(); ``` After this, `src/analyze.ts` ends right after `analyzeRepository`. Same housekeeping we did to `sandbox-lifecycle.ts` back in 2.3, same reason. \*\*Warning: Troubleshooting: zero files collected\*\* If `Collected 0 file(s)` shows up against a real repo, your interest list doesn't match its layout. Open the README in the cloned repo by hand and adjust the paths. For this course, picking a repo with a standard Next.js or Node layout works best. \*\*Note: Troubleshooting: long analysis times\*\* `generateObject` calls take 5–30 seconds depending on file size. That's normal. If it consistently times out, you may be sending too much context; trim the interest list. ## Try It ```bash pnpm review https://github.com/vercel/examples ``` Expected output: ```txt Reviewing https://github.com/vercel/examples... Sandbox: repo-review-... Collected 2 file(s) for analysis. Overall risk: low Findings: 3 [medium] Missing input validation on shared utility (package.json) [low] Inconsistent use of optional chaining (src/index.ts) [low] No timeout configured for outgoing fetches (src/index.ts) ``` The specific findings will vary by run and by repo. What matters is the shape: a number, a risk level, a list of severity-labeled findings tied to actual files. ## Commit ```bash git add src/sandbox-lifecycle.ts src/cli.ts git commit -m "feat(cli): wire analyzer into the review pipeline" ``` ## Done-When - [ ] Lifecycle returns `files: Array<{ path, content }>` instead of a README preview - [ ] CLI imports and calls `analyzeRepository(lifecycle.files)` - [ ] Findings print one per line with severity, summary, and file - [ ] Exit codes (0/1/2) still behave as in 2.4 - [ ] Temporary `main()` block at the bottom of `src/analyze.ts` is deleted ## Solution ```ts title="src/sandbox-lifecycle.ts" import { Sandbox } from '@vercel/sandbox'; const INTERESTING_PATHS = [ 'repo/package.json', 'repo/src/index.ts', 'repo/src/app.ts', 'repo/lib/auth.ts' ]; export type LifecycleResult = { sandboxName: string; cloneExitCode: number; files: Array<{ path: string; content: string }>; }; export async function runSandboxLifecycle(repoUrl: string): Promise { const sandbox = await Sandbox.create({ persistent: false, timeout: 10 * 60 * 1000 }); try { const clone = await sandbox.runCommand('git', ['clone', '--depth', '1', repoUrl, 'repo']); if (clone.exitCode !== 0) { throw new Error(`Clone failed: ${await clone.stderr()}`); } const files: Array<{ path: string; content: string }> = []; for (const fullPath of INTERESTING_PATHS) { const content = await sandbox.readFileToBuffer({ path: fullPath }); if (content) { files.push({ path: fullPath.replace(/^repo\//, ''), content: content.toString('utf8') }); } } return { sandboxName: sandbox.name, cloneExitCode: clone.exitCode, files }; } finally { await sandbox.stop(); } } ``` --- title: "Run Tests Inside the Sandbox" description: "Static AI analysis only tells you what the model thinks. In this lesson, we actually run the repo's test suite inside the Sandbox, capture stdout and stderr, and keep the result around so we can parse it in the next lesson." canonical_url: "https://vercel.com/academy/vercel-sandbox/run-tests-inside-the-sandbox" md_url: "https://vercel.com/academy/vercel-sandbox/run-tests-inside-the-sandbox.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-21T16:55:07.337Z" content_type: "lesson" course: "vercel-sandbox" course_title: "Vercel Sandbox" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Run Tests Inside the Sandbox # Run the Test Suite in the Sandbox Static analysis is helpful, but if tests are red and we ignore them, that is code review cosplay. The whole reason Sandboxes exist is so we can actually execute the code, not just read it. The model is allowed to be wrong, and the tests are the ground truth for at least one definition of "works." So let's run them. ## Outcome Extend the lifecycle to run `pnpm test` inside the Sandbox after the file collection step, and return the test command's exit code, stdout, and stderr. ## Fast Track 1. After file collection, run `pnpm install` with `cwd: 'repo'` so dependencies are present. 2. Run `pnpm test` with the same working directory and capture the result. 3. Return `testResult: { exitCode, stdout, stderr }` from the lifecycle. ## Hands-on exercise Open `src/sandbox-lifecycle.ts` and add the test step: ```ts import { Sandbox } from '@vercel/sandbox'; const INTERESTING_PATHS = [ 'repo/package.json', 'repo/src/index.ts', 'repo/src/app.ts', 'repo/lib/auth.ts' ]; export type TestResult = { exitCode: number; stdout: string; stderr: string; }; export type LifecycleResult = { sandboxName: string; cloneExitCode: number; files: Array<{ path: string; content: string }>; testResult: TestResult; }; export async function runSandboxLifecycle(repoUrl: string): Promise { const sandbox = await Sandbox.create({ persistent: false, timeout: 10 * 60 * 1000 }); try { const clone = await sandbox.runCommand('git', ['clone', '--depth', '1', repoUrl, 'repo']); if (clone.exitCode !== 0) { throw new Error(`Clone failed: ${await clone.stderr()}`); } const files: Array<{ path: string; content: string }> = []; for (const fullPath of INTERESTING_PATHS) { const content = await sandbox.readFileToBuffer({ path: fullPath }); if (content) { files.push({ path: fullPath.replace(/^repo\//, ''), content: content.toString('utf8') }); } } const install = await sandbox.runCommand({ cmd: 'pnpm', args: ['install'], cwd: 'repo' }); if (install.exitCode !== 0) { throw new Error(`Install failed: ${await install.stderr()}`); } const test = await sandbox.runCommand({ cmd: 'pnpm', args: ['test'], cwd: 'repo' }); return { sandboxName: sandbox.name, cloneExitCode: clone.exitCode, files, testResult: { exitCode: test.exitCode, stdout: await test.stdout(), stderr: await test.stderr() } }; } finally { await sandbox.stop(); } } ``` A couple of intentional choices. We're not throwing when `pnpm test` exits non-zero. Failing tests are a *finding*, not a crash. The whole point of running them is that the failures become part of the review. We're also not parsing anything yet. Parsing is the next lesson. Right now we just need to prove the tests ran and we captured the output. \*\*Warning: Troubleshooting: pnpm is not available\*\* Some Sandbox base images don't have pnpm. If `pnpm install` returns "command not found", we'll handle that in 4.3 when we detect the lockfile and pick the matching tool. For now, pick a repo that uses pnpm. \*\*Note: Troubleshooting: install takes forever\*\* `pnpm install` on a real repo can take 30-90 seconds. That's normal for a cold Sandbox. We'll speed it up with snapshots in Chapter 5. \*\*Warning: Untrusted scripts can access the network by default\*\* Package installation and tests execute repository-controlled scripts. Sandbox isolates them from your machine, but its default network policy allows internet access. A production reviewer should allow only the registries needed for installation, then deny or tightly restrict egress before running untrusted tests. ## Try It Pick a repo that uses pnpm and has a test script (a small one — install time matters): ```bash pnpm review https://github.com/ ``` Expected output (the trailing test summary varies): ```txt Reviewing https://github.com/<...>... Sandbox: repo-review-... Collected 2 file(s) for analysis. Overall risk: low Findings: 3 [medium] ... ... ``` You won't see the test output in the terminal yet because the CLI isn't logging it. To verify it's there, temporarily add `console.log(lifecycle.testResult)` in the CLI right after the lifecycle call. You should see something like: ```js { exitCode: 0, stdout: '> repo@1.0.0 test\n> vitest run\n\n ✓ src/sum.test.ts (3)\n...', stderr: '' } ``` Or if tests failed: ```js { exitCode: 1, stdout: '... FAIL src/sum.test.ts > adds correctly ...', stderr: '' } ``` Either way, the suite ran and we captured the result. Remove the debug log before committing. ## Commit ```bash git add src/sandbox-lifecycle.ts git commit -m "feat(sandbox): run repo test suite and capture the result" ``` ## Done-When - [ ] `pnpm install` runs in the Sandbox before tests - [ ] `pnpm test` runs after install - [ ] `testResult: { exitCode, stdout, stderr }` is returned from the lifecycle - [ ] Non-zero test exit codes don't crash the lifecycle ## Solution ```ts title="src/sandbox-lifecycle.ts" import { Sandbox } from '@vercel/sandbox'; const INTERESTING_PATHS = [ 'repo/package.json', 'repo/src/index.ts', 'repo/src/app.ts', 'repo/lib/auth.ts' ]; export type TestResult = { exitCode: number; stdout: string; stderr: string; }; export type LifecycleResult = { sandboxName: string; cloneExitCode: number; files: Array<{ path: string; content: string }>; testResult: TestResult; }; export async function runSandboxLifecycle(repoUrl: string): Promise { const sandbox = await Sandbox.create({ persistent: false, timeout: 10 * 60 * 1000 }); try { const clone = await sandbox.runCommand('git', ['clone', '--depth', '1', repoUrl, 'repo']); if (clone.exitCode !== 0) { throw new Error(`Clone failed: ${await clone.stderr()}`); } const files: Array<{ path: string; content: string }> = []; for (const fullPath of INTERESTING_PATHS) { const content = await sandbox.readFileToBuffer({ path: fullPath }); if (content) { files.push({ path: fullPath.replace(/^repo\//, ''), content: content.toString('utf8') }); } } const install = await sandbox.runCommand({ cmd: 'pnpm', args: ['install'], cwd: 'repo' }); if (install.exitCode !== 0) { throw new Error(`Install failed: ${await install.stderr()}`); } const test = await sandbox.runCommand({ cmd: 'pnpm', args: ['test'], cwd: 'repo' }); return { sandboxName: sandbox.name, cloneExitCode: clone.exitCode, files, testResult: { exitCode: test.exitCode, stdout: await test.stdout(), stderr: await test.stderr() } }; } finally { await sandbox.stop(); } } ``` --- title: "Parse Test Failures" description: "Test runner output is a wall of text. Useful for humans, useless for merging with structured AI findings. In this lesson, we write a small parser that pulls failure lines out of stdout/stderr and shapes them into typed `TestFinding` records." canonical_url: "https://vercel.com/academy/vercel-sandbox/parse-test-failures" md_url: "https://vercel.com/academy/vercel-sandbox/parse-test-failures.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-21T16:55:07.360Z" content_type: "lesson" course: "vercel-sandbox" course_title: "Vercel Sandbox" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Parse Test Failures # Parse Test Output into Structured Findings Test runner output is a wall of text. Useful for humans reading a terminal, useless for anything else. We want to put test failures next to AI findings in the same report. That means giving them the same shape: severity, summary, location. A small parser turns "FAIL src/auth.test.ts > login rejects empty password" into a structured record we can sort, count, and serialize. ## Outcome Create `src/test-runner.ts` with a `parseTestFailures(output)` function that returns an array of `TestFinding` objects, one per failed assertion or suite. ## Fast Track 1. Create `src/test-runner.ts`. 2. Define a `TestFinding` type with `severity`, `category`, `summary`, and `details`. 3. Write `parseTestFailures(output)` that filters lines for failure markers and maps them. ## Hands-on exercise Create `src/test-runner.ts`: ```ts export type TestFinding = { severity: 'medium' | 'high'; category: 'test-failure'; summary: string; details: string; }; const FAILURE_MARKERS = ['FAIL ', '✕ ', '× ']; export function parseTestFailures(output: string): TestFinding[] { const lines = output.split('\n'); return lines .map((line) => line.trim()) .filter((line) => FAILURE_MARKERS.some((marker) => line.startsWith(marker))) .map((line) => ({ severity: 'high' as const, category: 'test-failure' as const, summary: 'Automated test failure', details: line })); } ``` A few things worth pointing out. The parser is intentionally dumb. It looks for known failure markers and pulls those lines out. It doesn't try to attribute failures to specific source files, doesn't try to count assertions, doesn't try to extract diff output. Test runners are wildly inconsistent in how they format failures, and a generic parser that gets 80% of cases right is far more useful than a fragile one that breaks on the next runner. The markers cover common shapes across Jest, Vitest, Mocha, and other reporters: - `FAIL ` is a common failed-suite prefix - `✕ ` and `× ` are common failed-test glyphs If a runner uses a different marker, the parser silently misses those failures. That's a known limitation, not a bug. In a real tool you'd extend this list as you encounter new runners. We're also typing severity as `'high'` for every failure. A failing test is a failing test; the parser isn't smart enough to distinguish "this one assertion is wrong" from "the whole suite blew up." The AI findings keep the four-level severity scale; test findings are binary. \*\*Warning: Troubleshooting: no failures detected when tests are red\*\* If `pnpm test` exits non-zero but `parseTestFailures` returns an empty array, the runner is using a marker we don't recognize. Print the raw output, find the failure line, and add its prefix to `FAILURE_MARKERS`. \*\*Note: Troubleshooting: too many false positives\*\* The parser uses `startsWith()` after trimming each line, so markers are anchored to the beginning. If a reporter prefixes timestamps or log levels, normalize those prefixes before matching rather than switching back to `includes()`. ## Try It Test the parser without booting a Sandbox. Add a temporary check at the bottom of `src/test-runner.ts`: ```ts const sampleOutput = ` > repo@1.0.0 test > vitest run ✓ src/sum.test.ts (3) ✕ src/auth.test.ts > login rejects empty password ✕ src/auth.test.ts > login rejects short password Test Files 1 failed | 1 passed Tests 2 failed | 3 passed FAIL src/auth.test.ts `; console.log(parseTestFailures(sampleOutput)); ``` Run it: ```bash pnpm tsx src/test-runner.ts ``` Expected output: ```js [ { severity: 'high', category: 'test-failure', summary: 'Automated test failure', details: '✕ src/auth.test.ts > login rejects empty password' }, { severity: 'high', category: 'test-failure', summary: 'Automated test failure', details: '✕ src/auth.test.ts > login rejects short password' }, { severity: 'high', category: 'test-failure', summary: 'Automated test failure', details: 'FAIL src/auth.test.ts' } ] ``` Three structured records. Delete the sample block before moving on. ## Commit ```bash git add src/test-runner.ts git commit -m "feat(testing): parse runner output into structured findings" ``` ## Done-When - [ ] `src/test-runner.ts` exports `TestFinding` type and `parseTestFailures` function - [ ] Sample output produces one finding per failure line - [ ] Passing-only output produces an empty array - [ ] Each finding has `severity: 'high'` and `category: 'test-failure'` ## Solution ```ts title="src/test-runner.ts" export type TestFinding = { severity: 'medium' | 'high'; category: 'test-failure'; summary: string; details: string; }; const FAILURE_MARKERS = ['FAIL ', '✕ ', '× ']; export function parseTestFailures(output: string): TestFinding[] { const lines = output.split('\n'); return lines .map((line) => line.trim()) .filter((line) => FAILURE_MARKERS.some((marker) => line.startsWith(marker))) .map((line) => ({ severity: 'high' as const, category: 'test-failure' as const, summary: 'Automated test failure', details: line })); } ``` --- title: "Handle Package Manager Variants" description: "Not every repo uses pnpm. In this lesson, we detect the lockfile inside the cloned repo, pick `pnpm`/`npm`/`yarn` accordingly, and stop hardcoding the tool name." canonical_url: "https://vercel.com/academy/vercel-sandbox/handle-package-manager-variants" md_url: "https://vercel.com/academy/vercel-sandbox/handle-package-manager-variants.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-21T16:55:07.384Z" content_type: "lesson" course: "vercel-sandbox" course_title: "Vercel Sandbox" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Handle Package Manager Variants # Detect the Package Manager Hardcoding `pnpm test` worked for exactly one kind of repo. Running `pnpm install` in a repo with `package-lock.json` can create a new pnpm lockfile and resolve dependencies differently from the project's intended tool. We need to inspect the cloned repo and pick the matching package manager. ## Outcome Add a `detectPackageManager(sandbox)` helper to `src/test-runner.ts` that reads which lockfile the repo has and returns the matching package manager. Update the lifecycle to use it. ## Fast Track 1. In `src/test-runner.ts`, add `detectPackageManager(sandbox)` that checks for `pnpm-lock.yaml`, `yarn.lock`, or `package-lock.json`. 2. Return a `{ install, test }` command pair for each. 3. In `src/sandbox-lifecycle.ts`, call the helper and use its commands. ## Hands-on exercise Open `src/test-runner.ts` and add the detection helper: ```ts import type { Sandbox } from '@vercel/sandbox'; export type TestFinding = { severity: 'medium' | 'high'; category: 'test-failure'; summary: string; details: string; }; export type PackageManagerCommands = { name: 'pnpm' | 'npm' | 'yarn'; install: { cmd: string; args: string[]; cwd: string }; test: { cmd: string; args: string[]; cwd: string }; }; const FAILURE_MARKERS = ['FAIL ', '✕ ', '× ']; export async function detectPackageManager( sandbox: Sandbox, repoDir = 'repo' ): Promise { const checks: Array<{ file: string; commands: PackageManagerCommands }> = [ { file: 'pnpm-lock.yaml', commands: { name: 'pnpm', install: { cmd: 'pnpm', args: ['install'], cwd: repoDir }, test: { cmd: 'pnpm', args: ['test'], cwd: repoDir } } }, { file: 'yarn.lock', commands: { name: 'yarn', install: { cmd: 'yarn', args: ['install'], cwd: repoDir }, test: { cmd: 'yarn', args: ['test'], cwd: repoDir } } }, { file: 'package-lock.json', commands: { name: 'npm', install: { cmd: 'npm', args: ['install'], cwd: repoDir }, test: { cmd: 'npm', args: ['test'], cwd: repoDir } } } ]; for (const { file, commands } of checks) { if (await sandbox.fs.exists(`${repoDir}/${file}`)) { return commands; } } // No lockfile at all: default to npm install as the last-resort fallback return { name: 'npm', install: { cmd: 'npm', args: ['install'], cwd: repoDir }, test: { cmd: 'npm', args: ['test'], cwd: repoDir } }; } export function parseTestFailures(output: string): TestFinding[] { const lines = output.split('\n'); return lines .map((line) => line.trim()) .filter((line) => FAILURE_MARKERS.some((marker) => line.startsWith(marker))) .map((line) => ({ severity: 'high' as const, category: 'test-failure' as const, summary: 'Automated test failure', details: line })); } ``` The detection runs through the lockfile candidates in order and stops at the first match. If none of them match, we fall back to `npm install` without a lockfile, knowing the result may not be reproducible. That fallback exists so the tool doesn't crash on weird repos; it's not a real recommendation. Now update `src/sandbox-lifecycle.ts` to use it: ```ts import { Sandbox } from '@vercel/sandbox'; import { detectPackageManager } from './test-runner'; const INTERESTING_PATHS = [ 'repo/package.json', 'repo/src/index.ts', 'repo/src/app.ts', 'repo/lib/auth.ts' ]; export type TestResult = { exitCode: number; stdout: string; stderr: string; packageManager: string; }; export type LifecycleResult = { sandboxName: string; cloneExitCode: number; files: Array<{ path: string; content: string }>; testResult: TestResult; }; export async function runSandboxLifecycle(repoUrl: string): Promise { const sandbox = await Sandbox.create({ persistent: false, timeout: 10 * 60 * 1000 }); try { const clone = await sandbox.runCommand('git', ['clone', '--depth', '1', repoUrl, 'repo']); if (clone.exitCode !== 0) { throw new Error(`Clone failed: ${await clone.stderr()}`); } const files: Array<{ path: string; content: string }> = []; for (const fullPath of INTERESTING_PATHS) { const content = await sandbox.readFileToBuffer({ path: fullPath }); if (content) { files.push({ path: fullPath.replace(/^repo\//, ''), content: content.toString('utf8') }); } } const pm = await detectPackageManager(sandbox); const install = await sandbox.runCommand(pm.install); if (install.exitCode !== 0) { throw new Error(`Install failed: ${await install.stderr()}`); } const test = await sandbox.runCommand(pm.test); return { sandboxName: sandbox.name, cloneExitCode: clone.exitCode, files, testResult: { exitCode: test.exitCode, stdout: await test.stdout(), stderr: await test.stderr(), packageManager: pm.name } }; } finally { await sandbox.stop(); } } ``` The lifecycle no longer cares which package manager the repo uses. It asks, gets back a command pair, and runs them. \*\*Warning: Troubleshooting: detection returns wrong manager\*\* Some repos have multiple lockfiles (`pnpm-lock.yaml` and `package-lock.json`). The detection picks the first one in the priority order, which may not match what the repo's contributors actually use. If you hit this, swap the order in the `checks` array. \*\*Note: Troubleshooting: stricter installs in production\*\* We're using plain `install` commands for simplicity. Production code should inspect `package.json#packageManager`, enable the matching Corepack version, verify the binary exists, and choose its strict mode. For example, use `pnpm install --frozen-lockfile`, `npm ci`, Yarn Berry's `--immutable`, or Yarn 1's `--frozen-lockfile` as appropriate. ## Try It Run against a repo with `package-lock.json`: ```bash pnpm review https://github.com/ ``` Expected output: ```txt Reviewing https://github.com/<...>... Sandbox: sbx_7N2k4A... Collected 2 file(s) for analysis. Overall risk: low Findings: 2 ... ``` The tests should still run, even though the repo isn't a pnpm project. If you add a temporary log of `lifecycle.testResult.packageManager`, you'll see `"npm"` instead of `"pnpm"`. ## Commit ```bash git add src/test-runner.ts src/sandbox-lifecycle.ts git commit -m "feat(testing): detect package manager from lockfile" ``` ## Done-When - [ ] `detectPackageManager` returns `pnpm` for repos with `pnpm-lock.yaml` - [ ] Returns `yarn` for repos with `yarn.lock` - [ ] Returns `npm` for repos with `package-lock.json` - [ ] Falls back to `npm install` (loose) when no lockfile is present - [ ] Lifecycle uses the returned commands instead of hardcoded `pnpm` ## Solution ```ts title="src/test-runner.ts (relevant additions)" import type { Sandbox } from '@vercel/sandbox'; export type PackageManagerCommands = { name: 'pnpm' | 'npm' | 'yarn'; install: { cmd: string; args: string[]; cwd: string }; test: { cmd: string; args: string[]; cwd: string }; }; export async function detectPackageManager( sandbox: Sandbox, repoDir = 'repo' ): Promise { const checks: Array<{ file: string; commands: PackageManagerCommands }> = [ { file: 'pnpm-lock.yaml', commands: { name: 'pnpm', install: { cmd: 'pnpm', args: ['install'], cwd: repoDir }, test: { cmd: 'pnpm', args: ['test'], cwd: repoDir } } }, { file: 'yarn.lock', commands: { name: 'yarn', install: { cmd: 'yarn', args: ['install'], cwd: repoDir }, test: { cmd: 'yarn', args: ['test'], cwd: repoDir } } }, { file: 'package-lock.json', commands: { name: 'npm', install: { cmd: 'npm', args: ['install'], cwd: repoDir }, test: { cmd: 'npm', args: ['test'], cwd: repoDir } } } ]; for (const { file, commands } of checks) { if (await sandbox.fs.exists(`${repoDir}/${file}`)) { return commands; } } return { name: 'npm', install: { cmd: 'npm', args: ['install'], cwd: repoDir }, test: { cmd: 'npm', args: ['test'], cwd: repoDir } }; } ``` --- title: "Merge AI and Test Findings" description: "In the CLI, parse the test output from the lifecycle, turn failures into structured findings, and merge them with the AI review. Recalculate overall risk so a failing test bumps the report's severity even if the AI thought everything was fine." canonical_url: "https://vercel.com/academy/vercel-sandbox/merge-ai-and-test-findings" md_url: "https://vercel.com/academy/vercel-sandbox/merge-ai-and-test-findings.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-21T16:55:07.407Z" content_type: "lesson" course: "vercel-sandbox" course_title: "Vercel Sandbox" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Merge AI and Test Findings # Merge Findings into One Report Two sources of truth, one report. The AI thinks the code is fine. The tests are red. Both are real. The job of the merge step is to surface both, and to make sure a green AI review doesn't mask a broken build. ## Outcome Update the CLI to parse `lifecycle.testResult.stdout/stderr` into test findings, merge them with the AI review's findings, recalculate `overallRisk`, and print the unified result. ## Fast Track 1. Import `parseTestFailures` in the CLI. 2. After the analyzer call, parse test failures from the combined stdout/stderr. 3. Build a `combined` review where `overallRisk` escalates to `'high'` if any tests failed. ## Hands-on exercise Open `src/cli.ts` and update the action body: ```ts import { Command } from 'commander'; import { runSandboxLifecycle } from './sandbox-lifecycle'; import { analyzeRepository } from './analyze'; import { parseTestFailures } from './test-runner'; function isValidGitHubRepoUrl(input: string): boolean { return /^https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/?$/.test(input); } const program = new Command(); program .name('repo-review') .description('Clone and review a GitHub repository in a Sandbox') .version('0.1.0'); program .command('review ') .description('Run a Sandbox review against a GitHub repository URL') .action(async (repoUrl: string) => { if (!isValidGitHubRepoUrl(repoUrl)) { console.error(`Invalid GitHub repository URL: ${repoUrl}`); console.error('Expected format: https://github.com//'); process.exitCode = 2; return; } console.log(`Reviewing ${repoUrl}...`); try { const lifecycle = await runSandboxLifecycle(repoUrl); console.log(`Sandbox: ${lifecycle.sandboxName}`); console.log(`Collected ${lifecycle.files.length} file(s) for analysis.`); console.log(`Tests (${lifecycle.testResult.packageManager}): exit code ${lifecycle.testResult.exitCode}`); const aiReview = lifecycle.files.length === 0 ? { overallRisk: 'low' as const, findings: [] } : await analyzeRepository(lifecycle.files); const testFindings = parseTestFailures( `${lifecycle.testResult.stdout}\n${lifecycle.testResult.stderr}` ); const combined = { overallRisk: testFindings.length > 0 ? 'high' as const : aiReview.overallRisk, aiFindings: aiReview.findings, testFindings }; console.log(`\nOverall risk: ${combined.overallRisk}`); console.log(`AI findings: ${combined.aiFindings.length}`); for (const finding of combined.aiFindings) { console.log(` [${finding.severity}] ${finding.summary} (${finding.file})`); } console.log(`Test findings: ${combined.testFindings.length}`); for (const finding of combined.testFindings) { console.log(` [${finding.severity}] ${finding.details}`); } } catch (error) { console.error('Review failed:', error instanceof Error ? error.message : error); process.exitCode = 1; } }); await program.parseAsync(); ``` The combine rule is intentionally blunt: any test failure forces overall risk to high, regardless of what the AI thought. A passing test suite means we trust the AI's assessment. You could imagine more nuanced rules (severity-weighted blends, configurable thresholds), and those are reasonable in a production tool. For this course, blunt is fine. The point is that test failures stop being invisible to the summary. Notice we're also keeping AI findings and test findings as separate arrays in the output, not jamming them into one combined list. That makes the report easier to read; a reader can see at a glance "the AI thinks this, the tests prove that." The two lenses don't need to look identical. \*\*Warning: Troubleshooting: test findings empty when tests failed\*\* If the test exit code is non-zero but `combined.testFindings` is empty, the parser didn't recognize the failure markers. Print the raw stdout and check what your test runner uses. Add the marker to `FAILURE_MARKERS` in `test-runner.ts`. \*\*Note: Troubleshooting: combined risk feels too harsh\*\* If you want flaky test suites to not auto-escalate to high risk, add a `flakyAllowance` threshold (e.g. allow up to N failures before escalating). Out of scope for this course, but it's a one-line change. ## Try It Run against a repo with failing tests: ```bash pnpm review https://github.com/ ``` Expected output: ```txt Reviewing https://github.com/<...>... Sandbox: repo-review-... Collected 2 file(s) for analysis. Tests (pnpm): exit code 1 Overall risk: high AI findings: 2 [medium] Missing input validation (lib/auth.ts) [low] Inconsistent error handling (src/index.ts) Test findings: 2 [high] ✕ src/auth.test.ts > login rejects empty password [high] FAIL src/auth.test.ts ``` Then a clean repo: ```bash pnpm review https://github.com/ ``` ```txt Reviewing https://github.com/<...>... Sandbox: sbx_7N2k4A... Collected 2 file(s) for analysis. Tests (pnpm): exit code 0 Overall risk: low AI findings: 1 [low] Consider extracting magic number (src/index.ts) Test findings: 0 ``` Both reports are useful. The first one tells you to look at tests before merging; the second one tells you you're in fine shape. ## Commit ```bash git add src/cli.ts git commit -m "feat(cli): merge ai findings with test failures in one report" ``` ## Done-When - [ ] CLI imports and calls `parseTestFailures` - [ ] Combined report has separate `aiFindings` and `testFindings` sections - [ ] `overallRisk` escalates to `'high'` when any test failed - [ ] Clean run (no failures) preserves the AI's risk level ## Solution ```ts title="src/cli.ts (relevant section)" const lifecycle = await runSandboxLifecycle(repoUrl); const aiReview = lifecycle.files.length === 0 ? { overallRisk: 'low' as const, findings: [] } : await analyzeRepository(lifecycle.files); const testFindings = parseTestFailures( `${lifecycle.testResult.stdout}\n${lifecycle.testResult.stderr}` ); const combined = { overallRisk: testFindings.length > 0 ? 'high' as const : aiReview.overallRisk, aiFindings: aiReview.findings, testFindings }; console.log(`\nOverall risk: ${combined.overallRisk}`); console.log(`AI findings: ${combined.aiFindings.length}`); for (const finding of combined.aiFindings) { console.log(` [${finding.severity}] ${finding.summary} (${finding.file})`); } console.log(`Test findings: ${combined.testFindings.length}`); for (const finding of combined.testFindings) { console.log(` [${finding.severity}] ${finding.details}`); } ``` --- title: "Benchmark the Pipeline" description: "Wrap each pipeline stage with a stopwatch and log the durations. The numbers are boring, but they're the only way to know whether the next lesson's snapshot work actually does anything." canonical_url: "https://vercel.com/academy/vercel-sandbox/benchmark-the-pipeline" md_url: "https://vercel.com/academy/vercel-sandbox/benchmark-the-pipeline.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-21T16:55:07.460Z" content_type: "lesson" course: "vercel-sandbox" course_title: "Vercel Sandbox" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Benchmark the Pipeline # Measure Before You Optimize You can't optimize what you don't measure. Or you can, but you'll waste a week speeding up the thing that wasn't slow. The pipeline works end-to-end now: validate → boot Sandbox → clone → collect → install → test → analyze → report. Some of those stages are fast and some are slow, and right now we have no idea which is which. Before we add snapshots in the next lesson, let's get the baseline. ## Outcome Add timing measurements around each stage in `src/cli.ts` (or the lifecycle, your call), and print a per-stage breakdown plus total runtime at the end of every review. ## Fast Track 1. Define a `time(label, fn)` helper that wraps an async function and logs its duration. 2. Wrap each pipeline stage in the CLI with `time`. 3. Track total runtime separately and print a summary at the end. ## Hands-on exercise Open `src/cli.ts` and add the helper plus the timing wrappers. The cleanest spot for the helper is at the top of the file: ```ts import { Command } from 'commander'; import { runSandboxLifecycle } from './sandbox-lifecycle'; import { analyzeRepository } from './analyze'; import { parseTestFailures } from './test-runner'; function isValidGitHubRepoUrl(input: string): boolean { return /^https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/?$/.test(input); } async function time(label: string, fn: () => Promise): Promise { const startedAt = Date.now(); try { return await fn(); } finally { const durationMs = Date.now() - startedAt; console.log(` ⏱ ${label}: ${durationMs}ms`); } } const program = new Command(); program .name('repo-review') .description('Clone and review a GitHub repository in a Sandbox') .version('0.1.0'); program .command('review ') .description('Run a Sandbox review against a GitHub repository URL') .action(async (repoUrl: string) => { if (!isValidGitHubRepoUrl(repoUrl)) { console.error(`Invalid GitHub repository URL: ${repoUrl}`); console.error('Expected format: https://github.com//'); process.exitCode = 2; return; } console.log(`Reviewing ${repoUrl}...`); const totalStart = Date.now(); try { const lifecycle = await time('sandbox lifecycle', () => runSandboxLifecycle(repoUrl)); const aiReview = lifecycle.files.length === 0 ? { overallRisk: 'low' as const, findings: [] } : await time('ai analysis', () => analyzeRepository(lifecycle.files)); const testFindings = parseTestFailures( `${lifecycle.testResult.stdout}\n${lifecycle.testResult.stderr}` ); const combined = { overallRisk: testFindings.length > 0 ? 'high' as const : aiReview.overallRisk, aiFindings: aiReview.findings, testFindings }; console.log(`\nOverall risk: ${combined.overallRisk}`); console.log(`AI findings: ${combined.aiFindings.length}`); for (const finding of combined.aiFindings) { console.log(` [${finding.severity}] ${finding.summary} (${finding.file})`); } console.log(`Test findings: ${combined.testFindings.length}`); for (const finding of combined.testFindings) { console.log(` [${finding.severity}] ${finding.details}`); } console.log(`\nTotal: ${Date.now() - totalStart}ms`); } catch (error) { console.error('Review failed:', error instanceof Error ? error.message : error); process.exitCode = 1; } }); await program.parseAsync(); ``` The `time` helper uses `try/finally` so we log the duration even when the wrapped function throws. Knowing "ai analysis failed after 18 seconds" is way more useful than just knowing it failed. Right now the per-stage breakdown is coarse (just lifecycle + analysis). The lifecycle itself does several big things internally (boot, clone, install, test). If you want sub-stage timing inside the lifecycle, you can add the same `time` helper there. For now, the rough split is enough to see where the seconds go. \*\*Warning: Troubleshooting: timing shows 0ms\*\* If a stage prints `0ms`, something synchronous returned before any real work happened. Check that the function actually awaits something inside. \*\*Note: Troubleshooting: Date.now is good enough\*\* For benchmarking at the second/minute scale, `Date.now()` is fine. `performance.now()` gets you fractional milliseconds, which we don't need here. Don't overthink it. ## Try It ```bash pnpm review https://github.com/vercel/examples ``` Expected output: ```txt Reviewing https://github.com/vercel/examples... ⏱ sandbox lifecycle: 24180ms ⏱ ai analysis: 7240ms Overall risk: low AI findings: 2 ... Test findings: 0 Total: 31420ms ``` Two takeaways from a real run. First, the lifecycle dominates. Most of the 24 seconds is `pnpm install`, which we'll attack with snapshots next lesson. Second, the AI analysis is fixed overhead. Smaller prompts will be faster, larger ones slower, but it doesn't scale with repo size the way `install` does. Knowing this, the snapshot work in 5.2 is justified. Without these numbers it would have been a guess. ## Commit ```bash git add src/cli.ts git commit -m "feat(cli): add per-stage timing and total runtime" ``` ## Done-When - [ ] `time(label, fn)` helper logs duration even when the wrapped function throws - [ ] Each major stage is wrapped in `time` - [ ] Total runtime prints at the end of every successful review - [ ] Timing also prints when a stage fails (so we know where time was spent before the error) ## Solution ```ts title="src/cli.ts (helper)" async function time(label: string, fn: () => Promise): Promise { const startedAt = Date.now(); try { return await fn(); } finally { const durationMs = Date.now() - startedAt; console.log(` ⏱ ${label}: ${durationMs}ms`); } } ``` --- title: "Sandbox Snapshots for Speed" description: "The pipeline's slowest step is installing dependencies. Sandbox snapshots preserve a prepared filesystem so repeated runs can reuse a toolchain or warmed package cache. Add snapshot-ID restoration with an explicit unconfigured fallback." canonical_url: "https://vercel.com/academy/vercel-sandbox/sandbox-snapshots-for-speed" md_url: "https://vercel.com/academy/vercel-sandbox/sandbox-snapshots-for-speed.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-21T16:55:07.561Z" content_type: "lesson" course: "vercel-sandbox" course_title: "Vercel Sandbox" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Sandbox Snapshots for Speed # Use Snapshots to Skip the Cold Start The benchmark numbers from 5.1 told us where the time goes. Most of it lives inside `pnpm install`, which makes sense because cold Sandboxes start with no `node_modules` and have to fetch everything from scratch. Snapshots are how we reuse prepared filesystem state. A snapshot captures a running Sandbox's files and installed packages; it is distinct from the container image used to boot the Sandbox. Restoring one can skip toolchain setup or reuse a warmed package cache. A snapshot containing one repository's `node_modules` will not generally accelerate an unrelated repository. Aim for reusable toolchains and package caches, or create a snapshot specifically for a stable repository. ## Outcome Update `runSandboxLifecycle` to create the Sandbox from a configured snapshot ID. If no ID is configured, create a default throwaway Sandbox. If a configured ID is invalid, surface the error instead of disguising every failure as a missing snapshot. ## Fast Track 1. Read a snapshot ID from `SANDBOX_SNAPSHOT_ID`. 2. Restore with `source: { type: 'snapshot', snapshotId }`. 3. Use default creation only when the env var is absent. ## Hands-on exercise Open `src/sandbox-lifecycle.ts`. We're going to extract Sandbox creation into a small helper: ```ts import { Sandbox } from '@vercel/sandbox'; import { detectPackageManager } from './test-runner'; const INTERESTING_PATHS = [ 'repo/package.json', 'repo/src/index.ts', 'repo/src/app.ts', 'repo/lib/auth.ts' ]; const SNAPSHOT_ID = process.env.SANDBOX_SNAPSHOT_ID; async function createSandbox(): Promise<{ sandbox: Sandbox; usedSnapshot: boolean }> { if (!SNAPSHOT_ID) { console.warn('SANDBOX_SNAPSHOT_ID is not set; using a default Sandbox.'); const sandbox = await Sandbox.create({ persistent: false, timeout: 10 * 60 * 1000 }); return { sandbox, usedSnapshot: false }; } const sandbox = await Sandbox.create({ source: { type: 'snapshot', snapshotId: SNAPSHOT_ID }, persistent: false, timeout: 10 * 60 * 1000 }); return { sandbox, usedSnapshot: true }; } export type TestResult = { exitCode: number; stdout: string; stderr: string; packageManager: string; }; export type LifecycleResult = { sandboxName: string; usedSnapshot: boolean; cloneExitCode: number; files: Array<{ path: string; content: string }>; testResult: TestResult; }; export async function runSandboxLifecycle(repoUrl: string): Promise { const { sandbox, usedSnapshot } = await createSandbox(); try { const clone = await sandbox.runCommand('git', ['clone', '--depth', '1', repoUrl, 'repo']); if (clone.exitCode !== 0) { throw new Error(`Clone failed: ${await clone.stderr()}`); } const files: Array<{ path: string; content: string }> = []; for (const fullPath of INTERESTING_PATHS) { const content = await sandbox.readFileToBuffer({ path: fullPath }); if (content) { files.push({ path: fullPath.replace(/^repo\//, ''), content: content.toString('utf8') }); } } const pm = await detectPackageManager(sandbox); const install = await sandbox.runCommand(pm.install); if (install.exitCode !== 0) { throw new Error(`Install failed: ${await install.stderr()}`); } const test = await sandbox.runCommand(pm.test); return { sandboxName: sandbox.name, usedSnapshot, cloneExitCode: clone.exitCode, files, testResult: { exitCode: test.exitCode, stdout: await test.stdout(), stderr: await test.stderr(), packageManager: pm.name } }; } finally { await sandbox.stop(); } } ``` Three things to flag. The snapshot ID is configurable via `SANDBOX_SNAPSHOT_ID`. Snapshot IDs, rather than user-defined names, are what `Sandbox.create({ source })` restores. The fallback is only for an absent configuration. A configured but expired, deleted, or inaccessible snapshot should fail loudly so authentication and service errors are not misreported. The `usedSnapshot` flag flows back through the result so the CLI (or the reporter we build in 5.4) can show "snapshot accelerated" in the summary. Knowing whether the snapshot path was actually taken is the difference between "we have snapshots" and "snapshots actually help." To create one, prepare a Sandbox and call `snapshot()`. Snapshotting stops that Sandbox automatically: ```ts const base = await Sandbox.create({ persistent: false }); await base.runCommand('corepack', ['enable']); const snapshot = await base.snapshot({ expiration: 14 * 24 * 60 * 60 * 1000 }); console.log(snapshot.snapshotId); ``` Snapshots expire after 30 days by default. Pass an explicit expiration when your retention needs differ. \*\*Warning: Troubleshooting: snapshot restore fails\*\* Confirm `SANDBOX_SNAPSHOT_ID` contains a current snapshot ID and that your Vercel credentials can access its project. Remove the variable only when you intentionally want default creation; don't catch every restore error as a fallback. \*\*Note: Troubleshooting: snapshot creation isn't free either\*\* Creating a snapshot includes the setup run and storage. Reuse it enough to justify that work, and measure the actual benefit for your repository mix. ## Try It Run without `SANDBOX_SNAPSHOT_ID`: ```bash pnpm review https://github.com/vercel/examples ``` Expected output: ```txt SANDBOX_SNAPSHOT_ID is not set; using a default Sandbox. Reviewing https://github.com/vercel/examples... ⏱ sandbox lifecycle: 24180ms ⏱ ai analysis: 7240ms Total: 31420ms ``` After exporting a valid snapshot ID and running again: ```txt Reviewing https://github.com/vercel/examples... ⏱ sandbox lifecycle: 9420ms ⏱ ai analysis: 7180ms Total: 16600ms ``` The unconfigured warning is gone. Your measured improvement depends on what the snapshot contains and how closely the reviewed repo matches it. AI analysis time should remain roughly the same because snapshots do not accelerate model calls. ## Commit ```bash git add src/sandbox-lifecycle.ts git commit -m "feat(sandbox): create from named snapshot with graceful fallback" ``` ## Done-When - [ ] `Sandbox.create({ source: { type: 'snapshot', snapshotId } })` restores a configured snapshot - [ ] Default creation runs only when `SANDBOX_SNAPSHOT_ID` is absent - [ ] Invalid configured snapshot errors remain visible - [ ] `usedSnapshot` flag is returned in `LifecycleResult` - [ ] `SANDBOX_SNAPSHOT_ID` selects the snapshot to restore ## Solution ```ts title="src/sandbox-lifecycle.ts (helper)" const SNAPSHOT_ID = process.env.SANDBOX_SNAPSHOT_ID; async function createSandbox(): Promise<{ sandbox: Sandbox; usedSnapshot: boolean }> { if (!SNAPSHOT_ID) { console.warn('SANDBOX_SNAPSHOT_ID is not set; using a default Sandbox.'); const sandbox = await Sandbox.create({ persistent: false, timeout: 10 * 60 * 1000 }); return { sandbox, usedSnapshot: false }; } const sandbox = await Sandbox.create({ source: { type: 'snapshot', snapshotId: SNAPSHOT_ID }, persistent: false, timeout: 10 * 60 * 1000 }); return { sandbox, usedSnapshot: true }; } ``` --- title: "Resilient Error Handling" description: "Right now, a failure anywhere in the pipeline aborts the whole review. In this lesson, we wrap each stage so a broken test runner doesn't hide the AI findings (and a failed AI call doesn't hide the test results)." canonical_url: "https://vercel.com/academy/vercel-sandbox/resilient-error-handling" md_url: "https://vercel.com/academy/vercel-sandbox/resilient-error-handling.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-21T16:55:07.594Z" content_type: "lesson" course: "vercel-sandbox" course_title: "Vercel Sandbox" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Resilient Error Handling # Don't Let One Broken Stage Kill the Whole Run The current pipeline is all-or-nothing. If the AI call times out, you get no findings, no test results, and no idea what was wrong with the repo. If the test runner blows up, the AI review is wasted. Production tools don't behave like that. A failed test step should still let the AI review come through, and vice versa. We want partial results. ## Outcome Refactor the CLI's pipeline so each stage (lifecycle, AI analysis, test parsing) catches its own errors and reports them as warnings in the final output, instead of aborting the whole run. ## Fast Track 1. Add a `safe(label, fn, fallback)` helper that runs `fn` and returns `fallback` if it throws, logging a warning. 2. Wrap the AI analysis call in `safe`. 3. Keep the lifecycle outside the safe wrapper (a failed lifecycle is fatal; everything else builds on it). ## Hands-on exercise Open `src/cli.ts`. We're adding one helper and changing how stages are wrapped: ```ts import { Command } from 'commander'; import { runSandboxLifecycle } from './sandbox-lifecycle'; import { analyzeRepository } from './analyze'; import { parseTestFailures } from './test-runner'; function isValidGitHubRepoUrl(input: string): boolean { return /^https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/?$/.test(input); } async function time(label: string, fn: () => Promise): Promise { const startedAt = Date.now(); try { return await fn(); } finally { console.log(` ⏱ ${label}: ${Date.now() - startedAt}ms`); } } async function safe(label: string, fn: () => Promise, fallback: T): Promise { try { return await fn(); } catch (error) { console.warn( `⚠ ${label} failed: ${error instanceof Error ? error.message : error}` ); return fallback; } } const program = new Command(); program .name('repo-review') .description('Clone and review a GitHub repository in a Sandbox') .version('0.1.0'); program .command('review ') .description('Run a Sandbox review against a GitHub repository URL') .action(async (repoUrl: string) => { if (!isValidGitHubRepoUrl(repoUrl)) { console.error(`Invalid GitHub repository URL: ${repoUrl}`); console.error('Expected format: https://github.com//'); process.exitCode = 2; return; } console.log(`Reviewing ${repoUrl}...`); const totalStart = Date.now(); try { const lifecycle = await time('sandbox lifecycle', () => runSandboxLifecycle(repoUrl)); const aiReview = lifecycle.files.length === 0 ? { overallRisk: 'low' as const, findings: [] } : await time('ai analysis', () => safe( 'ai analysis', () => analyzeRepository(lifecycle.files), { overallRisk: 'low' as const, findings: [] } ) ); const testFindings = safe( 'test parsing', async () => parseTestFailures( `${lifecycle.testResult.stdout}\n${lifecycle.testResult.stderr}` ), [] ); const resolvedTestFindings = await testFindings; const combined = { overallRisk: resolvedTestFindings.length > 0 ? 'high' as const : aiReview.overallRisk, aiFindings: aiReview.findings, testFindings: resolvedTestFindings }; console.log(`\nOverall risk: ${combined.overallRisk}`); console.log(`AI findings: ${combined.aiFindings.length}`); for (const finding of combined.aiFindings) { console.log(` [${finding.severity}] ${finding.summary} (${finding.file})`); } console.log(`Test findings: ${combined.testFindings.length}`); for (const finding of combined.testFindings) { console.log(` [${finding.severity}] ${finding.details}`); } console.log(`\nTotal: ${Date.now() - totalStart}ms`); } catch (error) { console.error('Review failed:', error instanceof Error ? error.message : error); process.exitCode = 1; } }); await program.parseAsync(); ``` The rule is: catch errors at stages that can fail independently, let everything else propagate. The lifecycle stays outside `safe` because if the Sandbox didn't boot or the clone failed, there's literally nothing to review. That's a real abort condition. The AI analysis is the textbook `safe` candidate. It can time out, hit rate limits, or fail schema validation, and none of those are reasons to throw away a perfectly good test report. Test parsing is wrapped too, even though `parseTestFailures` is pure and shouldn't ever throw. Defensive habit: any future change that adds I/O to the parser would suddenly have a failure mode the caller wasn't expecting. We're not changing the exit code logic. If the lifecycle dies, we still exit 1. If the AI fails but the test parser succeeds, we exit 0 with a warning. The review is partial, but it ran. \*\*Warning: Troubleshooting: stage warnings still throw\*\* If you see a stack trace instead of a `⚠` warning, the error is escaping `safe`. Most likely the wrapped function does `setImmediate(() => { throw ... })` or similar deferred throws, which `try/catch` can't catch. Convert those to `Promise.reject(...)` instead. \*\*Note: Troubleshooting: when to abort vs continue\*\* The rule of thumb: abort when the next stage literally cannot run without this one's output. Continue when the next stage can run with a fallback. The lifecycle has to abort; analysis and parsing don't. ## Try It Force an AI failure to see partial results. Easiest way: temporarily set the model in `src/analyze.ts` to a model you don't have access to. Then run: ```bash pnpm review https://github.com/ ``` Expected output: ```txt Reviewing https://github.com/<...>... ⏱ sandbox lifecycle: 9420ms ⚠ ai analysis failed: Model "openai/nonsense-model" not available ⏱ ai analysis: 1240ms Overall risk: low AI findings: 0 Test findings: 0 Total: 10660ms ``` Note three things: - The pipeline didn't abort. Exit code is 0. - AI findings are empty (fallback) and a warning explains why. - The test result still came through. Reset the model in `src/analyze.ts` back to a real one before moving on. ## Commit ```bash git add src/cli.ts git commit -m "feat(cli): per-stage error handling so one failure doesn't abort the run" ``` ## Done-When - [ ] `safe(label, fn, fallback)` helper returns the fallback on error and logs a warning - [ ] AI analysis failures don't abort the pipeline - [ ] Lifecycle failures still abort with exit code 1 - [ ] Partial results print with `⚠` warnings explaining what failed ## Solution ```ts title="src/cli.ts (helper)" async function safe(label: string, fn: () => Promise, fallback: T): Promise { try { return await fn(); } catch (error) { console.warn( `⚠ ${label} failed: ${error instanceof Error ? error.message : error}` ); return fallback; } } ``` --- title: "Formatted Reports" description: "Move the print logic out of the CLI into a small reporter module. Sort findings by severity, group AI vs test results, and skip ANSI colors when running in CI so the logs stay clean." canonical_url: "https://vercel.com/academy/vercel-sandbox/formatted-reports" md_url: "https://vercel.com/academy/vercel-sandbox/formatted-reports.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-21T16:55:07.619Z" content_type: "lesson" course: "vercel-sandbox" course_title: "Vercel Sandbox" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Formatted Reports # Print a Report Worth Reading The first version works. Great. It is also slow, noisy, and mildly dramatic when anything goes wrong. We fixed slow (snapshots) and dramatic (resilient error handling). The last problem is noisy. Findings come out in whatever order the model returned them, with no severity grouping, and the formatting is whatever a series of `console.log` calls happened to produce. Time to put a reporter in front of it. ## Outcome Create `src/reporter.ts` with `printReview(review)` that sorts findings by severity, groups AI vs test findings, and suppresses ANSI color codes when `CI=true`. Update the CLI to call it. ## Fast Track 1. Create `src/reporter.ts` exporting a `printReview` function. 2. Sort findings by severity (critical → high → medium → low). 3. Skip color codes when `process.env.CI === 'true'`. 4. Replace the print loop in `src/cli.ts` with a single `printReview(combined)` call. ## Hands-on exercise Create `src/reporter.ts`: ```ts import type { Finding } from './analyze'; import type { TestFinding } from './test-runner'; export type CombinedReview = { overallRisk: 'low' | 'medium' | 'high'; aiFindings: Finding[]; testFindings: TestFinding[]; }; const SEVERITY_RANK: Record = { critical: 0, high: 1, medium: 2, low: 3 }; const useColor = process.env.CI !== 'true'; function color(code: string, text: string): string { if (!useColor) return text; return `\x1b[${code}m${text}\x1b[0m`; } function riskColor(risk: CombinedReview['overallRisk']): string { if (risk === 'high') return '31'; // red if (risk === 'medium') return '33'; // yellow return '32'; // green } function severityColor(severity: Finding['severity']): string { if (severity === 'critical' || severity === 'high') return '31'; if (severity === 'medium') return '33'; return '90'; // gray } export function printReview(review: CombinedReview): void { const totalFindings = review.aiFindings.length + review.testFindings.length; const riskLabel = review.overallRisk.toUpperCase(); console.log(''); console.log(`Overall risk: ${color(riskColor(review.overallRisk), riskLabel)}`); console.log(`Total findings: ${totalFindings}`); console.log(''); if (review.aiFindings.length > 0) { console.log('AI findings:'); const sorted = [...review.aiFindings].sort( (a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity] ); for (const finding of sorted) { const tag = color(severityColor(finding.severity), `[${finding.severity.toUpperCase()}]`); console.log(` ${tag} ${finding.summary} (${finding.file})`); console.log(` → ${finding.recommendation}`); } console.log(''); } if (review.testFindings.length > 0) { console.log('Test findings:'); for (const finding of review.testFindings) { const tag = color(severityColor(finding.severity), `[${finding.severity.toUpperCase()}]`); console.log(` ${tag} ${finding.details}`); } console.log(''); } if (totalFindings === 0) { console.log('No findings. The repo is clean by both AI and test signals.'); console.log(''); } } ``` A few decisions worth pointing at. The CI detection (`process.env.CI === 'true'`) is the only thing standing between a developer reading colorful output in their terminal and a CI log full of `\x1b[31m` garbage. Most CI systems set `CI=true`; the few that don't will just get colors, which is harmless. We're sorting AI findings but not test findings. AI findings have four severity levels and benefit from ordering; test findings are all `'high'` so sorting them doesn't change anything. We're printing the recommendation under each AI finding (`→ ...`) instead of just the summary. The recommendation is the actionable part. A summary without it is just a complaint. The "no findings" message exists because zero results currently looks like the report just stopped halfway through. A confirmation message tells the reader nothing went wrong. Now wire it in. Open `src/cli.ts` and replace the print loop: ```ts import { Command } from 'commander'; import { runSandboxLifecycle } from './sandbox-lifecycle'; import { analyzeRepository } from './analyze'; import { parseTestFailures } from './test-runner'; import { printReview, type CombinedReview } from './reporter'; function isValidGitHubRepoUrl(input: string): boolean { return /^https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/?$/.test(input); } async function time(label: string, fn: () => Promise): Promise { const startedAt = Date.now(); try { return await fn(); } finally { console.log(` ⏱ ${label}: ${Date.now() - startedAt}ms`); } } async function safe(label: string, fn: () => Promise, fallback: T): Promise { try { return await fn(); } catch (error) { console.warn(`⚠ ${label} failed: ${error instanceof Error ? error.message : error}`); return fallback; } } const program = new Command(); program .name('repo-review') .description('Clone and review a GitHub repository in a Sandbox') .version('0.1.0'); program .command('review ') .description('Run a Sandbox review against a GitHub repository URL') .action(async (repoUrl: string) => { if (!isValidGitHubRepoUrl(repoUrl)) { console.error(`Invalid GitHub repository URL: ${repoUrl}`); console.error('Expected format: https://github.com//'); process.exitCode = 2; return; } console.log(`Reviewing ${repoUrl}...`); const totalStart = Date.now(); try { const lifecycle = await time('sandbox lifecycle', () => runSandboxLifecycle(repoUrl)); const aiReview = lifecycle.files.length === 0 ? { overallRisk: 'low' as const, findings: [] } : await time('ai analysis', () => safe( 'ai analysis', () => analyzeRepository(lifecycle.files), { overallRisk: 'low' as const, findings: [] } ) ); const testFindings = parseTestFailures( `${lifecycle.testResult.stdout}\n${lifecycle.testResult.stderr}` ); const combined: CombinedReview = { overallRisk: testFindings.length > 0 ? 'high' : aiReview.overallRisk, aiFindings: aiReview.findings, testFindings }; printReview(combined); console.log(`Total: ${Date.now() - totalStart}ms`); } catch (error) { console.error('Review failed:', error instanceof Error ? error.message : error); process.exitCode = 1; } }); await program.parseAsync(); ``` The CLI got shorter, which is the right direction. Anything report-shaped now lives in the reporter, and the CLI does only what a CLI should: parse args, orchestrate stages, hand off results. \*\*Warning: Troubleshooting: colors in CI logs\*\* If you see `\x1b[31m` escape codes in your CI output, your CI provider isn't setting `CI=true`. Check the docs or hardcode `process.env.CI = 'true'` in a CI-specific config. \*\*Note: Troubleshooting: want JSON output instead\*\* If you want the report as machine-readable JSON (for piping into another tool), add a `--json` flag to the CLI and have it call `console.log(JSON.stringify(combined, null, 2))` instead of `printReview`. The reporter and the JSON path can coexist. ## Try It ```bash pnpm review https://github.com/vercel/examples ``` Expected output: ```txt Reviewing https://github.com/vercel/examples... ⏱ sandbox lifecycle: 9420ms ⏱ ai analysis: 7180ms Overall risk: LOW Total findings: 3 AI findings: [HIGH] Unsanitized shell interpolation in build script (scripts/release.ts) → Use a templated argument array (execFileSync) instead of string interpolation. [MEDIUM] Missing timeout on external fetch (src/lib/http.ts) → Pass an AbortController with a 10s timeout to fetch(). [LOW] Test assertion uses broad matcher (tests/api.test.ts) → Replace toMatch(/.+/) with the specific expected value. Total: 16600ms ``` And in CI mode: ```bash CI=true pnpm review https://github.com/vercel/examples ``` Same content, no color escapes. Easy to read in either context. ## Commit ```bash git add src/reporter.ts src/cli.ts git commit -m "feat(reporter): severity-sorted, ci-aware review output" ``` ## Done-When - [ ] `src/reporter.ts` exports `printReview` and `CombinedReview` - [ ] AI findings sort critical → high → medium → low - [ ] `CI=true` suppresses ANSI escape codes - [ ] CLI calls `printReview(combined)` instead of inline loops - [ ] Empty findings produce a "no findings" confirmation message ## Solution ```ts title="src/reporter.ts" import type { Finding } from './analyze'; import type { TestFinding } from './test-runner'; export type CombinedReview = { overallRisk: 'low' | 'medium' | 'high'; aiFindings: Finding[]; testFindings: TestFinding[]; }; const SEVERITY_RANK: Record = { critical: 0, high: 1, medium: 2, low: 3 }; const useColor = process.env.CI !== 'true'; function color(code: string, text: string): string { if (!useColor) return text; return `\x1b[${code}m${text}\x1b[0m`; } function riskColor(risk: CombinedReview['overallRisk']): string { if (risk === 'high') return '31'; if (risk === 'medium') return '33'; return '32'; } function severityColor(severity: Finding['severity']): string { if (severity === 'critical' || severity === 'high') return '31'; if (severity === 'medium') return '33'; return '90'; } export function printReview(review: CombinedReview): void { const totalFindings = review.aiFindings.length + review.testFindings.length; const riskLabel = review.overallRisk.toUpperCase(); console.log(''); console.log(`Overall risk: ${color(riskColor(review.overallRisk), riskLabel)}`); console.log(`Total findings: ${totalFindings}`); console.log(''); if (review.aiFindings.length > 0) { console.log('AI findings:'); const sorted = [...review.aiFindings].sort( (a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity] ); for (const finding of sorted) { const tag = color(severityColor(finding.severity), `[${finding.severity.toUpperCase()}]`); console.log(` ${tag} ${finding.summary} (${finding.file})`); console.log(` → ${finding.recommendation}`); } console.log(''); } if (review.testFindings.length > 0) { console.log('Test findings:'); for (const finding of review.testFindings) { const tag = color(severityColor(finding.severity), `[${finding.severity.toUpperCase()}]`); console.log(` ${tag} ${finding.details}`); } console.log(''); } if (totalFindings === 0) { console.log('No findings. The repo is clean by both AI and test signals.'); console.log(''); } } ``` --- title: "Agent-Friendly APIs" description: "Build a feedback API, then build a Claude Code skill that generates the documentation agents need to use it." canonical_url: "https://vercel.com/academy/agent-friendly-apis" md_url: "https://vercel.com/academy/agent-friendly-apis.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-09-22T04:50:22.744Z" content_type: "course" lessons: 12 estimated_time: lesson_urls: - "https://vercel.com/academy/agent-friendly-apis/setup-project.md" - "https://vercel.com/academy/agent-friendly-apis/feedback-endpoint.md" - "https://vercel.com/academy/agent-friendly-apis/filtering-and-details.md" - "https://vercel.com/academy/agent-friendly-apis/summary-endpoint.md" - "https://vercel.com/academy/agent-friendly-apis/agent-friendly-docs.md" - "https://vercel.com/academy/agent-friendly-apis/add-llms-txt.md" - "https://vercel.com/academy/agent-friendly-apis/deploy-your-docs.md" - "https://vercel.com/academy/agent-friendly-apis/explore-real-skills.md" - "https://vercel.com/academy/agent-friendly-apis/anatomy-of-a-skill.md" - "https://vercel.com/academy/agent-friendly-apis/build-the-generator.md" - "https://vercel.com/academy/agent-friendly-apis/run-and-evaluate.md" - "https://vercel.com/academy/agent-friendly-apis/iterate-and-ship.md" --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Agent-Friendly APIs API documentation now has another audience. Agents read it to decide which endpoint to call, what arguments to send, and how to recover from errors. You'll build a feedback API for a fictional cooking school and serve its documentation from a live endpoint. Then you'll build a Claude Code skill that generates the documentation from the route handlers. ## What you'll learn - How to build a JSON-backed API with Next.js App Router - What agents need from API documentation - How to serve API docs as a markdown endpoint - How Claude Code skills work: SKILL.md, frontmatter, progressive disclosure - How to build and test a skill that generates structured documentation ## Prerequisites - Basic TypeScript - Familiarity with Next.js App Router (API routes) - A Vercel account - Claude Code installed ## Course sections **Section 1: Build the API.** Scaffold a Next.js project and build a JSON-backed feedback API with query parameter filtering and a summary route. **Section 2: Agent-Friendly Documentation.** Learn what agents need from docs, implement the llms.txt standard, and deploy the API to Vercel. You'll also inspect production skills on skills.sh. **Section 3: Build the Skill.** Build and run a Claude Code skill that generates the docs. Evaluate its output against a quality checklist and refine the instructions until the endpoint works. --- title: "Project Setup" description: "Deploy the starter project, review the Feedback type and seed data, and implement the data utility functions that the API routes will use." canonical_url: "https://vercel.com/academy/agent-friendly-apis/setup-project" md_url: "https://vercel.com/academy/agent-friendly-apis/setup-project.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-26T21:03:36.381Z" content_type: "lesson" course: "agent-friendly-apis" course_title: "Agent-Friendly APIs" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Project Setup # Set Up the Project Every cooking school collects opinions. Instructors hear them through emails, sticky notes, and hallway conversations. We'll collect that feedback in a JSON-backed API with enough structure to query, filter, and summarize it. This course stays in the terminal because the API is the project. ## Outcome Scaffold a Next.js project with a `Feedback` type and a JSON file of seed data. ## Fast Track 1. Deploy the starter repo to Vercel, then clone it locally 2. Review the `Feedback` interface in `lib/types.ts` and the seed data in `data/feedback.json` 3. Implement the three functions in `lib/data.ts` ## Hands-on exercise Start by deploying the starter repo. This gets you a live URL right away, which we'll need when we add agent-friendly docs later in the course. The deployed GET routes can read the bundled seed file, but the JSON-writing exercise in this section is for local development only. [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel-labs%2Fcooking-school-feedback\&repository-name=cooking-school-feedback) Once Vercel creates your project, clone it locally and install dependencies: ```bash git clone cd cooking-school-feedback pnpm install ``` The starter provides a Next.js app with App Router and TypeScript configured. This API project does not need a styling library. The starter includes the type definition and seed data. You'll implement the data utility. **`lib/types.ts`** already has the `Feedback` interface. Open it and confirm it has these fields: - `id` (string), unique identifier like `"fb-001"` - `courseSlug` (string), which course the feedback belongs to - `lessonSlug` (string), which lesson within the course - `rating` (number), integer from 1 to 5 - `comment` (string), the feedback text - `author` (string), who submitted it - `createdAt` (string), ISO 8601 timestamp **`data/feedback.json`** contains 10 feedback entries for `knife-skills`, `bread-baking`, and `pasta-from-scratch`. The range of ratings will make the summary endpoint useful later. Open **`lib/data.ts`**. The function signatures are present, but the implementations are stubs: - `getAllFeedback()`, reads the file and returns the parsed array - `getFeedbackById(id)`, finds a single entry by its `id` - `addFeedback(entry)`, generates an `id` and `createdAt`, appends to the file, returns the new entry Use `fs/promises` for file operations and `path.join(process.cwd(), "data", "feedback.json")` for the file path. \*\*Note: ID generation\*\* Count the existing entries and pad the number. If there are 10 entries, the next id is `fb-011`. This approach is sufficient for the course project. ## Try It After implementing the functions, verify the setup by running the dev server and checking that the project compiles: ```bash pnpm dev ``` You should see: ``` ▲ Next.js 16.x (Turbopack) - Local: http://localhost:3000 ``` The project should compile cleanly. The data utility will become reachable after you add API routes in the next lesson. You can also verify the types work by opening `lib/data.ts` in your editor and confirming there are no TypeScript errors on the `Feedback` import. \*\*Warning: JSON file path\*\* The `process.cwd()` approach works in development and in `next build`. If you see a "file not found" error, make sure the `data/` folder is at the project root, not inside `app/`. \*\*Warning: Local writes are not production storage\*\* `fs.writeFile` updates the JSON file while you work locally. On Vercel, function filesystems are not durable or shared storage, so a POST may disappear on a later invocation. Use Vercel Blob or a database for production writes. \*\*Warning: If you see 'fs is not defined'\*\* Make sure you're importing from `fs/promises`, not `fs`. And double-check the import is `import fs from "fs/promises"`, not a named import like `import { readFile } from "fs"`. The data utility runs on the server, so Node built-ins are available, but the import path matters. \*\*Warning: If TypeScript complains about your function signatures\*\* Each function needs to be `async` since `fs.readFile` and `fs.writeFile` return Promises. If your editor shows a type error on the return value, check that you have `async` before the function keyword and that the return type matches (`Promise`, `Promise`, `Promise`). ## Commit ```bash git add -A && git commit -m "feat(api): scaffold project with feedback types and seed data" ``` ## Done-When - [ ] `lib/types.ts` exports a `Feedback` interface with all 7 fields (provided by starter) - [ ] `data/feedback.json` contains 10 seed feedback entries across three courses (provided by starter) - [ ] `lib/data.ts` has working implementations of `getAllFeedback`, `getFeedbackById`, and `addFeedback` - [ ] `pnpm dev` starts without errors You now have a typed interface, structured seed data, and a utility that reads and writes JSON. The next lesson exposes that data through an API route. ## Solution ```ts title="lib/types.ts" export interface Feedback { id: string; courseSlug: string; lessonSlug: string; rating: number; comment: string; author: string; createdAt: string; } ``` ```ts title="lib/data.ts" import fs from "fs/promises"; import path from "path"; import type { Feedback } from "./types"; const DATA_PATH = path.join(process.cwd(), "data", "feedback.json"); export async function getAllFeedback(): Promise { const raw = await fs.readFile(DATA_PATH, "utf-8"); return JSON.parse(raw); } export async function getFeedbackById( id: string ): Promise { const all = await getAllFeedback(); return all.find((fb) => fb.id === id); } export async function addFeedback( entry: Omit ): Promise { const all = await getAllFeedback(); const newEntry: Feedback = { ...entry, id: `fb-${String(all.length + 1).padStart(3, "0")}`, createdAt: new Date().toISOString(), }; all.push(newEntry); await fs.writeFile(DATA_PATH, JSON.stringify(all, null, 2)); return newEntry; } ``` ```json title="data/feedback.json" [ { "id": "fb-001", "courseSlug": "knife-skills", "lessonSlug": "the-claw-grip", "rating": 5, "comment": "Finally understand why my onion cuts were uneven. The claw grip changed everything.", "author": "Priya Sharma", "createdAt": "2026-03-01T10:30:00Z" }, { "id": "fb-002", "courseSlug": "knife-skills", "lessonSlug": "dicing-onions", "rating": 4, "comment": "Great technique breakdown but I wish there was a slow-motion video. Hard to follow the horizontal cut at full speed.", "author": "Marcus Chen", "createdAt": "2026-03-01T14:15:00Z" }, { "id": "fb-003", "courseSlug": "bread-baking", "lessonSlug": "hydration-basics", "rating": 5, "comment": "I had no idea that flour-to-water ratio mattered this much. My loaves have been bricks for months. Not anymore.", "author": "Aisha Johnson", "createdAt": "2026-03-02T09:00:00Z" }, { "id": "fb-004", "courseSlug": "knife-skills", "lessonSlug": "the-claw-grip", "rating": 3, "comment": "Felt a bit rushed. Would have liked more time on finger positioning before jumping to speed drills.", "author": "Tom Kowalski", "createdAt": "2026-03-02T11:45:00Z" }, { "id": "fb-005", "courseSlug": "bread-baking", "lessonSlug": "shaping-boules", "rating": 5, "comment": "The surface tension explanation finally clicked. My dough used to spread flat every time.", "author": "Sofia Reyes", "createdAt": "2026-03-03T08:20:00Z" }, { "id": "fb-006", "courseSlug": "bread-baking", "lessonSlug": "sourdough-starter", "rating": 4, "comment": "Good lesson, but the difference between a stiff starter and a liquid starter took me a second read.", "author": "James Okafor", "createdAt": "2026-03-03T16:00:00Z" }, { "id": "fb-007", "courseSlug": "knife-skills", "lessonSlug": "julienne-cuts", "rating": 5, "comment": "Matchstick carrots used to take me forever. The guide-hand technique cut my prep time in half.", "author": "Lin Wei", "createdAt": "2026-03-04T10:10:00Z" }, { "id": "fb-008", "courseSlug": "bread-baking", "lessonSlug": "hydration-basics", "rating": 2, "comment": "I already knew this from other baking courses. Wish there was a fast-track for people who have the fundamentals.", "author": "Derek Miles", "createdAt": "2026-03-04T13:30:00Z" }, { "id": "fb-009", "courseSlug": "knife-skills", "lessonSlug": "dicing-onions", "rating": 5, "comment": "Best lesson in the course. No more crying over mangled onion pieces.", "author": "Nora Eriksson", "createdAt": "2026-03-05T09:45:00Z" }, { "id": "fb-010", "courseSlug": "pasta-from-scratch", "lessonSlug": "egg-dough", "rating": 4, "comment": "The well method is so satisfying. Dough came together on the first try, which never happens to me.", "author": "Priya Sharma", "createdAt": "2026-03-05T15:20:00Z" } ] ``` --- title: "Feedback Endpoint" description: "Create the main feedback API route that lists all entries with GET and accepts new submissions with POST, including validation for required fields and rating range." canonical_url: "https://vercel.com/academy/agent-friendly-apis/feedback-endpoint" md_url: "https://vercel.com/academy/agent-friendly-apis/feedback-endpoint.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-26T21:03:36.398Z" content_type: "lesson" course: "agent-friendly-apis" course_title: "Agent-Friendly APIs" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Feedback Endpoint # Build the Feedback Endpoint The feedback data needs two operations: reading existing entries and submitting a new one. Both handlers will live on one route: GET to read and POST to write. ## Outcome Build a `/api/feedback` route that returns all feedback entries on GET and creates new ones on POST. ## Fast Track 1. Open `app/api/feedback/route.ts` (stub provided in starter) 2. Implement the `GET` handler to return all feedback as JSON 3. Implement the `POST` handler to validate the body and add the entry ## Hands-on exercise Open `app/api/feedback/route.ts`. The starter has this file with stub handlers. In Next.js App Router, the file path determines the URL. A file at `app/api/feedback/route.ts` handles requests to `/api/feedback`. **The GET handler** should: 1. Call `getAllFeedback()` from your data utility 2. Return the results as JSON with `NextResponse.json()` Return every entry for now. The next lesson adds filtering. **The POST handler** needs to: 1. Parse the JSON body from the request 2. Validate that all required fields are present: `courseSlug`, `lessonSlug`, `rating`, `comment`, `author` 3. Validate that `rating` is an integer between 1 and 5 4. Call `addFeedback()` with the validated data 5. Return the new entry with a `201` status For validation errors, return a `400` status with a JSON body containing an `error` field. Be specific about what went wrong. Vague error messages like `"Bad request"` are unhelpful for humans and even worse for agents (we'll come back to this point in Section 2). ```ts title="app/api/feedback/route.ts" import { NextRequest, NextResponse } from "next/server"; import { getAllFeedback, addFeedback } from "@/lib/data"; ``` Start with those imports, then implement the two handlers. \*\*Note: Why validate on the server?\*\* Clients, including agents, will POST directly to this endpoint. Validate input at the server boundary. ## Try It Start the dev server and test both handlers with curl. **List all feedback:** ```bash curl http://localhost:3000/api/feedback ``` You should see all 10 seed entries. The first one will look like this: ```json [ { "id": "fb-001", "courseSlug": "knife-skills", "lessonSlug": "the-claw-grip", "rating": 5, "comment": "Finally understand why my onion cuts were uneven. The claw grip changed everything.", "author": "Priya Sharma", "createdAt": "2026-03-01T10:30:00Z" }, ... ] ``` **Submit new feedback:** ```bash curl -X POST http://localhost:3000/api/feedback \ -H "Content-Type: application/json" \ -d '{ "courseSlug": "bread-baking", "lessonSlug": "scoring-dough", "rating": 5, "comment": "The lame technique demo was incredibly helpful.", "author": "Alex Turner" }' ``` You should get back the new entry with a generated `id` and `createdAt`: ```json { "id": "fb-011", "courseSlug": "bread-baking", "lessonSlug": "scoring-dough", "rating": 5, "comment": "The lame technique demo was incredibly helpful.", "author": "Alex Turner", "createdAt": "2026-03-06T12:00:00Z" } ``` **Missing fields test:** ```bash curl -X POST http://localhost:3000/api/feedback \ -H "Content-Type: application/json" \ -d '{}' ``` ```json { "error": "Missing required fields: courseSlug, lessonSlug, rating, comment, author" } ``` **Bad rating test:** ```bash curl -X POST http://localhost:3000/api/feedback \ -H "Content-Type: application/json" \ -d '{ "courseSlug": "bread-baking", "lessonSlug": "scoring-dough", "rating": 11, "comment": "Off the charts", "author": "Alex Turner" }' ``` ```json { "error": "Rating must be an integer between 1 and 5" } ``` \*\*Warning: POST modifies your seed data\*\* Every successful POST appends to `data/feedback.json`. If your test data gets messy, reset it with `git checkout data/feedback.json`. \*\*Warning: If POST rejects the request body\*\* Include `-H "Content-Type: application/json"` and send valid JSON. The handler catches JSON parse failures and returns a descriptive `400` response. \*\*Warning: If GET returns an empty array\*\* Your seed data file might have been overwritten by a bad POST. Open `data/feedback.json` and check that it still has the original 10 entries. If it's empty or malformed, copy it fresh from the starter repo. The route can now list and create feedback. Next, you'll add filters so clients can request a smaller result set. ## Commit ```bash git add -A && git commit -m "feat(api): add GET and POST handlers for /api/feedback" ``` ## Done-When - [ ] `GET /api/feedback` returns all entries from the JSON file - [ ] `POST /api/feedback` creates a new entry and returns it with status 201 - [ ] Missing fields return a 400 with a descriptive error message - [ ] Invalid rating returns a 400 with a descriptive error message ## Solution ```ts title="app/api/feedback/route.ts" import { NextRequest, NextResponse } from "next/server"; import { getAllFeedback, addFeedback } from "@/lib/data"; export async function GET(request: NextRequest) { const feedback = await getAllFeedback(); return NextResponse.json(feedback); } export async function POST(request: NextRequest) { let body; try { body = await request.json(); } catch { return NextResponse.json( { error: "Request body must be valid JSON" }, { status: 400 } ); } const { courseSlug, lessonSlug, rating, comment, author } = body; if (!courseSlug || !lessonSlug || rating == null || !comment || !author) { return NextResponse.json( { error: "Missing required fields: courseSlug, lessonSlug, rating, comment, author" }, { status: 400 } ); } if (!Number.isInteger(rating) || rating < 1 || rating > 5) { return NextResponse.json( { error: "Rating must be an integer between 1 and 5" }, { status: 400 } ); } const entry = await addFeedback({ courseSlug, lessonSlug, rating, comment, author }); return NextResponse.json(entry, { status: 201 }); } ``` --- title: "Filtering and Details" description: "Extend the feedback API with query parameter filtering on the list endpoint and a dynamic route segment for fetching individual entries by ID." canonical_url: "https://vercel.com/academy/agent-friendly-apis/filtering-and-details" md_url: "https://vercel.com/academy/agent-friendly-apis/filtering-and-details.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-26T21:03:36.420Z" content_type: "lesson" course: "agent-friendly-apis" course_title: "Agent-Friendly APIs" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Filtering and Details # Add Filtering and Details When someone asks what students are saying about the knife skills course, the API should return that course's feedback instead of the full dataset. You'll add query parameters to narrow the list and a route for fetching one entry by ID. ## Outcome Add query param filtering to `GET /api/feedback` and create a `GET /api/feedback/:id` route. ## Fast Track 1. Add `courseSlug`, `lessonSlug`, and `minRating` query param support to the GET handler 2. Implement the GET handler in `app/api/feedback/[id]/route.ts` 3. Return 404 for unknown IDs ## Hands-on exercise **Part 1: Query parameters** Update the existing `GET` handler in `app/api/feedback/route.ts` to support three optional query parameters: - `courseSlug`: filter entries where `courseSlug` matches exactly - `lessonSlug`: filter entries where `lessonSlug` matches exactly - `minRating`: filter entries where `rating` is greater than or equal to the value Pull these from `request.nextUrl.searchParams`. Each filter is optional. If none are provided, return everything (the current behavior). If multiple are provided, apply them all. ```ts const { searchParams } = request.nextUrl; const courseSlug = searchParams.get("courseSlug"); ``` **Part 2: Single feedback by ID** Open `app/api/feedback/[id]/route.ts`. The starter has this file with a stub handler. The brackets in `[id]` make this a dynamic segment. Next.js passes the value through the `params` prop. In Next.js 16, `params` is a Promise. You need to await it: ```ts export async function GET( request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { const { id } = await params; // ... } ``` Look up the feedback using `getFeedbackById`. If it doesn't exist, return a `404` with an error message that includes the requested ID. An agent can use that message to decide what to try next. \*\*Warning: Async params in Next.js 16\*\* If you forget to `await params`, TypeScript reports that `id` is a Promise. In Next.js 16, route params are asynchronous. \*\*Warning: Validate minRating explicitly\*\* Query parameters arrive as strings. Convert `minRating` with `Number()`, then verify it is an integer from 1 to 5. Relying on implicit coercion or `parseInt()` can silently accept malformed values such as `4stars` or produce `NaN`. \*\*Warning: If you see 'params.id is a Promise'\*\* In Next.js 16, `params` is async. If your TypeScript shows an error like "Property 'id' does not exist on type 'Promise'", you forgot to `await params`. The signature needs `{ params }: { params: Promise<{ id: string }> }` and you must `const { id } = await params` before using it. \*\*Warning: If filters return all entries instead of filtering\*\* Check that slug comparisons use strict equality (`===`) and `minRating` uses `>=` with the parsed integer. Each `if` block must also update the filtered result. ## Try It **Filter by course:** ```bash curl "http://localhost:3000/api/feedback?courseSlug=knife-skills" ``` Should return only the knife-skills entries (5 in the seed data). **Filter by minimum rating:** ```bash curl "http://localhost:3000/api/feedback?minRating=5" ``` Should return only 5-star entries. **Combine filters:** ```bash curl "http://localhost:3000/api/feedback?courseSlug=bread-baking&minRating=4" ``` Only bread-baking entries rated 4 or above. **Fetch a single entry:** ```bash curl http://localhost:3000/api/feedback/fb-001 ``` ```json { "id": "fb-001", "courseSlug": "knife-skills", "lessonSlug": "the-claw-grip", "rating": 5, "comment": "Finally understand why my onion cuts were uneven. The claw grip changed everything.", "author": "Priya Sharma", "createdAt": "2026-03-01T10:30:00Z" } ``` **Fetch a nonexistent entry:** ```bash curl http://localhost:3000/api/feedback/fb-999 ``` ```json { "error": "Feedback with id \"fb-999\" not found" } ``` Clients can now request feedback by course, minimum rating, or ID without reading the full dataset. ## Commit ```bash git add -A && git commit -m "feat(api): add query param filtering and single-feedback route" ``` ## Done-When - [ ] `GET /api/feedback?courseSlug=knife-skills` returns only knife-skills feedback - [ ] `GET /api/feedback?minRating=5` returns only 5-star entries - [ ] Multiple query params combine (AND logic) - [ ] `GET /api/feedback/fb-001` returns a single entry - [ ] `GET /api/feedback/fb-999` returns 404 with an error message that includes the ID ## Solution ```ts title="app/api/feedback/route.ts" {5-8,12-23} import { NextRequest, NextResponse } from "next/server"; import { getAllFeedback, addFeedback } from "@/lib/data"; export async function GET(request: NextRequest) { const { searchParams } = request.nextUrl; const courseSlug = searchParams.get("courseSlug"); const lessonSlug = searchParams.get("lessonSlug"); const minRating = searchParams.get("minRating"); let feedback = await getAllFeedback(); if (courseSlug) { feedback = feedback.filter((fb) => fb.courseSlug === courseSlug); } if (lessonSlug) { feedback = feedback.filter((fb) => fb.lessonSlug === lessonSlug); } if (minRating) { const min = Number(minRating); if (!Number.isInteger(min) || min < 1 || min > 5) { return NextResponse.json( { error: "minRating must be an integer between 1 and 5" }, { status: 400 } ); } feedback = feedback.filter((fb) => fb.rating >= min); } return NextResponse.json(feedback); } export async function POST(request: NextRequest) { let body; try { body = await request.json(); } catch { return NextResponse.json( { error: "Request body must be valid JSON" }, { status: 400 } ); } const { courseSlug, lessonSlug, rating, comment, author } = body; if (!courseSlug || !lessonSlug || rating == null || !comment || !author) { return NextResponse.json( { error: "Missing required fields: courseSlug, lessonSlug, rating, comment, author" }, { status: 400 } ); } if (!Number.isInteger(rating) || rating < 1 || rating > 5) { return NextResponse.json( { error: "Rating must be an integer between 1 and 5" }, { status: 400 } ); } const entry = await addFeedback({ courseSlug, lessonSlug, rating, comment, author }); return NextResponse.json(entry, { status: 201 }); } ``` ```ts title="app/api/feedback/[id]/route.ts" import { NextRequest, NextResponse } from "next/server"; import { getFeedbackById } from "@/lib/data"; export async function GET( request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { const { id } = await params; const feedback = await getFeedbackById(id); if (!feedback) { return NextResponse.json( { error: `Feedback with id "${id}" not found` }, { status: 404 } ); } return NextResponse.json(feedback); } ``` --- title: "Summary Endpoint" description: "Create a summary route that calculates total entries, average rating, rating distribution, and per-course breakdowns from the feedback data." canonical_url: "https://vercel.com/academy/agent-friendly-apis/summary-endpoint" md_url: "https://vercel.com/academy/agent-friendly-apis/summary-endpoint.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-26T21:03:36.436Z" content_type: "lesson" course: "agent-friendly-apis" course_title: "Agent-Friendly APIs" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Summary Endpoint # Build the Summary Endpoint Individual entries answer specific questions. Aggregate statistics show how many people responded, the average rating, and which courses need attention. The summary endpoint will calculate those values in one request. ## Outcome Create a `GET /api/feedback/summary` route that returns aggregate statistics. ## Fast Track 1. Open `app/api/feedback/summary/route.ts` (stub provided in starter) 2. Calculate totals, averages, and rating distribution 3. Group stats by course ## Hands-on exercise Open `app/api/feedback/summary/route.ts`. The starter has this file with a stub handler. This endpoint supports one optional query parameter: `courseSlug`, which filters the data before aggregating. The response shape should look like this: ```json { "totalEntries": 10, "averageRating": 4.2, "ratingDistribution": { "1": 0, "2": 1, "3": 1, "4": 3, "5": 5 }, "courses": [ { "courseSlug": "knife-skills", "totalEntries": 5, "averageRating": 4.4 } ] } ``` A few implementation notes: **Rating distribution** is an object where keys are ratings 1 through 5 and values are counts. Initialize all five keys to zero before counting, so the response always includes every rating level, even if nobody gave a 1. **Average rating** should be rounded to one decimal place. `Math.round(value * 10) / 10` handles that cleanly. **Per-course breakdown** groups entries by `courseSlug` and computes `totalEntries` and `averageRating` for each. A `Map` works well here since you're building up state while iterating. **Empty state:** If there's no feedback (or the `courseSlug` filter matches nothing), return zeros across the board with an empty `courses` array. Don't return a 404. An empty summary is a valid summary. \*\*Note: Route ordering\*\* Next.js matches static routes before dynamic ones. `/api/feedback/summary` won't conflict with `/api/feedback/[id]` because `summary` is a static segment and `[id]` is dynamic. \*\*Warning: Division by zero on empty feedback\*\* If the feedback array is empty, dividing the sum of ratings by `feedback.length` gives you `NaN`. Your API would return `"averageRating": null` in JSON, which is confusing for any consumer. Handle the empty case explicitly and return `0` for the average before you ever reach the division. \*\*Warning: If averageRating shows null in the response\*\* You've hit the division-by-zero case. Check that you return early with zeros when the feedback array is empty, before computing the average. The empty check should come right after filtering. \*\*Warning: If ratingDistribution is missing keys\*\* Initialize all five rating keys (1 through 5) to zero before iterating. If you build the distribution by only counting what exists in the data, ratings with zero entries won't appear in the response. Agents expect a predictable shape. \*\*Note: The distribution relies on validated ratings\*\* Lesson 1.2 rejects non-integer ratings before writing them. That keeps every `fb.rating` in the 1-to-5 range expected by this fixed distribution object. ## Try It **Full summary:** ```bash curl http://localhost:3000/api/feedback/summary ``` ```json { "totalEntries": 10, "averageRating": 4.2, "ratingDistribution": { "1": 0, "2": 1, "3": 1, "4": 3, "5": 5 }, "courses": [ { "courseSlug": "knife-skills", "totalEntries": 5, "averageRating": 4.4 }, { "courseSlug": "bread-baking", "totalEntries": 4, "averageRating": 4.0 }, { "courseSlug": "pasta-from-scratch", "totalEntries": 1, "averageRating": 4.0 } ] } ``` **Summary for one course:** ```bash curl "http://localhost:3000/api/feedback/summary?courseSlug=knife-skills" ``` Should return stats for just the knife-skills entries. **Summary for a nonexistent course:** ```bash curl "http://localhost:3000/api/feedback/summary?courseSlug=underwater-basket-weaving" ``` ```json { "totalEntries": 0, "averageRating": 0, "ratingDistribution": { "1": 0, "2": 0, "3": 0, "4": 0, "5": 0 }, "courses": [] } ``` An empty result returns zeros rather than a 404. The API can now summarize feedback for the full school or a single course. ## Commit ```bash git add -A && git commit -m "feat(api): add summary endpoint with aggregate stats" ``` ## Done-When - [ ] `GET /api/feedback/summary` returns `totalEntries`, `averageRating`, `ratingDistribution`, and `courses` - [ ] `averageRating` is rounded to one decimal place - [ ] `ratingDistribution` always includes keys 1 through 5 - [ ] `courseSlug` query param filters before aggregating - [ ] Empty results return zeros, not a 404 ## Solution ```ts title="app/api/feedback/summary/route.ts" import { NextRequest, NextResponse } from "next/server"; import { getAllFeedback } from "@/lib/data"; export async function GET(request: NextRequest) { const { searchParams } = request.nextUrl; const courseSlug = searchParams.get("courseSlug"); let feedback = await getAllFeedback(); if (courseSlug) { feedback = feedback.filter((fb) => fb.courseSlug === courseSlug); } if (feedback.length === 0) { return NextResponse.json({ totalEntries: 0, averageRating: 0, ratingDistribution: { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 }, courses: [], }); } const avgRating = feedback.reduce((sum, fb) => sum + fb.rating, 0) / feedback.length; const distribution = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 } as Record; for (const fb of feedback) { distribution[fb.rating]++; } const courseMap = new Map(); for (const fb of feedback) { const existing = courseMap.get(fb.courseSlug) ?? { count: 0, sum: 0 }; existing.count++; existing.sum += fb.rating; courseMap.set(fb.courseSlug, existing); } const courses = [...courseMap.entries()].map(([slug, data]) => ({ courseSlug: slug, totalEntries: data.count, averageRating: Math.round((data.sum / data.count) * 10) / 10, })); return NextResponse.json({ totalEntries: feedback.length, averageRating: Math.round(avgRating * 10) / 10, ratingDistribution: distribution, courses, }); } ``` --- title: "Agent-Friendly Docs" description: "Learn why traditional API docs fail for AI agents and discover the specific patterns (endpoint signatures, parameter tables, realistic examples, error cases, and schemas) that make docs machine-parseable without sacrificing human readability." canonical_url: "https://vercel.com/academy/agent-friendly-apis/agent-friendly-docs" md_url: "https://vercel.com/academy/agent-friendly-apis/agent-friendly-docs.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-26T21:03:36.462Z" content_type: "lesson" course: "agent-friendly-apis" course_title: "Agent-Friendly APIs" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Agent-Friendly Docs # What Makes Docs Agent-Friendly Human readers scan headings, find an endpoint, and copy a curl command. When an example contains a small mistake or omits an error case, experience can help them fill the gap. Agents rely more heavily on what the documentation states. If the docs name a parameter `course_slug` while the API expects `courseSlug`, an agent may send the wrong name and receive a 400. An undocumented error leaves it without a recovery path. Placeholder values such as `"string"` can also end up in a request unchanged. The documentation needs to remove that ambiguity. ## Outcome Understand the seven patterns that make API documentation agent-friendly and recognize that you won't write these docs by hand. ## Fast Track 1. Learn the seven patterns that separate agent-friendly docs from human-only docs 2. See concrete before/after examples for each pattern 3. Understand why you'll automate doc generation instead of maintaining docs manually ## Endpoint signatures in code blocks Use code blocks for endpoint signatures so the method and path are explicit: ``` GET /api/feedback ``` A prose-only version leaves more room for interpretation: > Send a GET request to the feedback endpoint to retrieve all entries. The code block gives an agent an exact method and path to extract. Every endpoint in your docs should start with a code block containing the HTTP method and path. No extra words, no surrounding explanation inside the block. The code block is the source of truth. ## Parameters as tables Markdown tables give parameter definitions a consistent structure. Mixed-format bullet lists are harder to parse reliably. ```markdown | Parameter | Type | Required | Description | |-------------|--------|----------|-----------------------| | courseSlug | string | no | Filter by course slug | ``` Compare that to: > - `courseSlug` (optional) - a string that filters by course The table exposes stable column names and one parameter per row. Tables give agents a consistent shape to parse: column headers as keys, rows as entries. Every query parameter, every request body field gets a row. ## Curl examples with real values Every request example should use working values from the seed file. Avoid placeholders such as `"string"`, `"example"`, and `"YOUR_VALUE_HERE"`. ```bash curl -X POST "http://localhost:3000/api/feedback" \ -H "Content-Type: application/json" \ -d '{ "courseSlug": "bread-baking", "lessonSlug": "scoring-dough", "rating": 5, "comment": "The lame technique demo was incredibly helpful.", "author": "Alex Turner" }' ``` An agent may copy example values into a request. Seed data demonstrates the required format, casing, and data types with a request that can run unchanged. \*\*Warning: Placeholder values are landmines\*\* Agents treat example values as templates. If your curl example uses `"example-slug"`, an agent might send that exact string to your API. Use values from your actual seed data so the examples work when copied verbatim. ## Complete response bodies Show the full JSON response for every endpoint. No `...` or "and so on." Truncated examples teach agents to generate truncated requests. ```json { "id": "fb-001", "courseSlug": "knife-skills", "lessonSlug": "the-claw-grip", "rating": 5, "comment": "Finally understand why my onion cuts were uneven. The claw grip changed everything.", "author": "Priya Sharma", "createdAt": "2026-03-01T10:30:00Z" } ``` Complete response examples teach the agent the full data shape. ## Exhaustive error documentation Every error response gets its own block with the status code, the condition that triggers it, and the exact response body. ```markdown **Error response (400), missing fields:** \`\`\`json { "error": "Missing required fields: courseSlug, lessonSlug, rating, comment, author" } \`\`\` **Error response (400), invalid rating:** \`\`\`json { "error": "Rating must be an integer between 1 and 5" } \`\`\` ``` The label `Error response (STATUS), DESCRIPTION:` identifies the status code and trigger condition. Document each response shape so a client can handle failures programmatically. ## A schema section Parameter tables tell agents what an endpoint accepts. A schema section tells them the shape of every data type in the system. ```markdown ## Schema ### Feedback | Field | Type | Description | |-------------|--------|------------------------------------------| | id | string | Unique identifier (e.g. "fb-001") | | courseSlug | string | Slug of the course | | lessonSlug | string | Slug of the lesson | | rating | number | Integer from 1 to 5 | | comment | string | Feedback text | | author | string | Name of the person | | createdAt | string | ISO 8601 timestamp | ``` The schema section defines each field across the API, while endpoint tables identify which fields a request accepts. Together, they provide the information needed to construct valid requests. Include format hints ("ISO 8601 timestamp"), value constraints ("Integer from 1 to 5"), and example values where helpful. The agent doesn't read your TypeScript types. It reads the docs. ## Workflow examples The preceding patterns explain individual endpoints. Many tasks require a sequence of calls. To find the worst-performing lessons in a course, an agent must check the summary, filter low ratings, and fetch the relevant details. A workflow documents that order directly. Workflow examples show agents how endpoints chain together to accomplish a task: ```markdown ## Workflows ### Investigate low-rated feedback for a course 1. `GET /api/feedback/summary?courseSlug=knife-skills`: check the average rating and total entries 2. `GET /api/feedback?courseSlug=knife-skills&minRating=1`: pull all entries (minRating sets the floor, so 1 returns everything) 3. `GET /api/feedback/fb-003`: get the full details on a specific entry ### Submit and verify new feedback 1. `POST /api/feedback`: submit the feedback entry with all required fields 2. `GET /api/feedback/:id`: fetch the newly created entry using the `id` from the POST response 3. `GET /api/feedback/summary?courseSlug=bread-baking`: check updated stats for the course ``` Each step names the endpoint, the key parameters, and why you're making that call. The numbered sequence removes all ambiguity about what comes first. Endpoint docs explain how to make one call. Workflows explain how several calls accomplish a task. \*\*Note: Workflows are task-oriented\*\* Start with tasks someone would perform with the API. A workflow connects the required endpoints in the order needed to finish one of those tasks. ## Documentation for both audiences Structured examples and explicit error cases also help human developers. The same documentation can serve both audiences. \*\*Note: Better for humans too\*\* Consistent formatting, realistic examples, and complete error documentation reduce guesswork for any API consumer. ## You won't write these by hand Applying seven patterns to every endpoint creates a substantial maintenance burden. When the API changes, manually written docs can drift. In Section 3, you'll turn these patterns into a skill that generates documentation from the route handlers. First, you'll implement a reference version of the documentation. ## Try It No code changes in this lesson. Review the seven patterns above. You'll apply them when building the docs endpoint and again when the skill generates docs automatically. ## Commit No code changes to commit. ## Done-When - [ ] You can name the seven patterns that make docs agent-friendly: endpoint signatures, parameter tables, curl examples, complete responses, error documentation, schema section, workflow examples - [ ] You understand why real values matter more than placeholders in examples - [ ] You know why exhaustive error documentation is critical for agents - [ ] You understand that these docs will be generated by a skill, not written by hand ## Solution No code solution for this lesson. The patterns here become the template for the docs you'll implement in 2.2 and the skill you'll build in Section 3. --- title: "Add llms.txt" description: "Implement the llms.txt standard for the feedback API, add llms-full.txt for single-request access, and add markdown docs so agents can discover and read your API documentation in machine-readable formats." canonical_url: "https://vercel.com/academy/agent-friendly-apis/add-llms-txt" md_url: "https://vercel.com/academy/agent-friendly-apis/add-llms-txt.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-26T21:03:36.481Z" content_type: "lesson" course: "agent-friendly-apis" course_title: "Agent-Friendly APIs" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Add llms.txt # Add llms.txt and Markdown Access A menu posted outside a restaurant tells people what is available before they enter. Your API needs a discoverable index that serves the same purpose. An agent cannot discover documentation at `/api/docs` unless it knows the route exists. Without a conventional index, each prompt or integration must supply the URL. The `llms.txt` standard provides a file at a well-known path that describes the API and links to its documentation. ## Outcome Add `/llms.txt`, `/llms-full.txt`, and `/api/docs.md` routes to the feedback API so agents can discover, skim, and read your documentation in machine-readable formats. ## Fast Track 1. Fill in the llms.txt content in `app/llms.txt/route.ts` (stub provided in starter) 2. Fill in the complete docs in `app/llms-full.txt/route.ts` (stub provided in starter) 3. Fill in the markdown docs in `app/api/docs.md/route.ts` (stub provided in starter) 4. Verify all three endpoints return the correct content types ## The llms.txt standard The `llms.txt` spec (from [llmstxt.org](https://llmstxt.org)) defines a simple markdown format that lives at the root of your site. The structure looks like this: ```markdown # Project Name > A one-line summary of what this project does. A slightly longer description with context. ## Section Name - [Link Title](https://example.com/path): Description of the resource - [Another Link](https://example.com/other): What this resource provides ``` The format includes: - **H1 with the project name** (required) - **Blockquote** with a brief summary - **Description paragraph** with more context - **H2 sections** with markdown link lists pointing to your endpoints and docs Vercel uses this pattern for its own docs. You can see the canonical index at [vercel.com/llms.txt](https://vercel.com/llms.txt). The current llms.txt proposal defines the index format; `llms-full.txt` is a useful community convention rather than a required part of that proposal. We'll build both forms. \*\*Note: Why plain text?\*\* Serve llms.txt as `text/plain`. Agents can fetch the raw text and parse its Markdown structure without relying on a Markdown-specific content type. ## Build the llms.txt endpoint The starter already has `app/llms.txt/route.ts` with a TODO stub. Open it up and replace the placeholder content with the real llms.txt markup: ```ts title="app/llms.txt/route.ts" import { NextResponse } from "next/server"; const llmsTxt = `# Cooking Course Feedback API > API for submitting and retrieving student feedback on cooking course lessons. This API serves feedback data for a cooking course platform. Students can submit ratings and comments on individual lessons, retrieve feedback filtered by course or rating, and view aggregate statistics. ## API Documentation - [API Docs](/api/docs): Full endpoint reference with parameters, examples, and error cases - [API Docs (Markdown)](/api/docs.md): Same documentation in .md format - [Full Documentation](/llms-full.txt): Complete API docs in a single file ## Endpoints - [List feedback](/api/feedback): GET all feedback entries, with optional filtering - [Get feedback by ID](/api/feedback/:id): GET a single feedback entry - [Submit feedback](/api/feedback): POST a new feedback entry - [Feedback summary](/api/feedback/summary): GET aggregate statistics `; export async function GET() { return new NextResponse(llmsTxt, { headers: { "Content-Type": "text/plain; charset=utf-8", }, }); } ``` Next.js serves the `llms.txt` route folder at `/llms.txt`. Its response includes the project name, a blockquote summary, a description, and sections linking to the API resources. \*\*Note: The /api/docs link doesn't work yet\*\* The `/api/docs` route still contains a placeholder. Section 3 replaces it with output generated by your skill. Linking it now establishes the route where clients will find the generated documentation. Until then, `/api/docs.md` is the working endpoint. ## Add llms-full.txt The llms.txt file is an index of available resources. Some clients instead need the complete documentation in one response. `llms-full.txt` provides that complete response. Vercel's docs site offers `llms.txt` for selective browsing and `llms-full.txt` for loading the full documentation. The size difference is small for this API. As an API grows, clients can use `llms.txt` to fetch one resource or `llms-full.txt` to load the complete reference. The starter has `app/llms-full.txt/route.ts` with a TODO stub. Fill it in with the project overview and the complete API documentation: ```ts title="app/llms-full.txt/route.ts" import { NextResponse } from "next/server"; const llmsFullTxt = `# Cooking Course Feedback API > API for submitting and retrieving student feedback on cooking course lessons. This API serves feedback data for a cooking course platform. Students can submit ratings and comments on individual lessons, retrieve feedback filtered by course or rating, and view aggregate statistics. ## Endpoints ### List feedback \`\`\` GET /api/feedback \`\`\` Returns all feedback entries. Supports optional query parameters for filtering. **Query parameters:** | Parameter | Type | Description | |--------------|--------|--------------------------------------| | courseSlug | string | Filter by course slug | | lessonSlug | string | Filter by lesson slug | | minRating | number | Only return entries rated >= value | **Example request:** \`\`\`bash curl "http://localhost:3000/api/feedback?courseSlug=knife-skills" \`\`\` **Example response:** \`\`\`json [ { "id": "fb-001", "courseSlug": "knife-skills", "lessonSlug": "the-claw-grip", "rating": 5, "comment": "Finally understand why my onion cuts were uneven. The claw grip changed everything.", "author": "Priya Sharma", "createdAt": "2026-03-01T10:30:00Z" } ] \`\`\` ### Get feedback by ID \`\`\` GET /api/feedback/:id \`\`\` Returns a single feedback entry. **Example request:** \`\`\`bash curl "http://localhost:3000/api/feedback/fb-001" \`\`\` **Example response:** \`\`\`json { "id": "fb-001", "courseSlug": "knife-skills", "lessonSlug": "the-claw-grip", "rating": 5, "comment": "Finally understand why my onion cuts were uneven. The claw grip changed everything.", "author": "Priya Sharma", "createdAt": "2026-03-01T10:30:00Z" } \`\`\` **Error response (404):** \`\`\`json { "error": "Feedback with id \\"fb-999\\" not found" } \`\`\` ### Submit feedback \`\`\` POST /api/feedback \`\`\` Creates a new feedback entry. The \`id\` and \`createdAt\` fields are generated automatically. **Request body (JSON):** | Field | Type | Required | Description | |-------------|--------|----------|---------------------------------| | courseSlug | string | yes | Slug of the course | | lessonSlug | string | yes | Slug of the lesson | | rating | number | yes | Rating from 1 to 5 | | comment | string | yes | Feedback text | | author | string | yes | Name of the person | **Example request:** \`\`\`bash curl -X POST "http://localhost:3000/api/feedback" \\ -H "Content-Type: application/json" \\ -d '{ "courseSlug": "bread-baking", "lessonSlug": "scoring-dough", "rating": 5, "comment": "The lame technique demo was incredibly helpful.", "author": "Alex Turner" }' \`\`\` **Example response (201):** \`\`\`json { "id": "fb-011", "courseSlug": "bread-baking", "lessonSlug": "scoring-dough", "rating": 5, "comment": "The lame technique demo was incredibly helpful.", "author": "Alex Turner", "createdAt": "2026-03-06T12:00:00Z" } \`\`\` **Error response (400), missing fields:** \`\`\`json { "error": "Missing required fields: courseSlug, lessonSlug, rating, comment, author" } \`\`\` **Error response (400), invalid rating:** \`\`\`json { "error": "Rating must be an integer between 1 and 5" } \`\`\` ### Get summary \`\`\` GET /api/feedback/summary \`\`\` Returns aggregate statistics across all feedback. Optionally filter by course. **Query parameters:** | Parameter | Type | Description | |-------------|--------|------------------------| | courseSlug | string | Filter by course slug | **Example request:** \`\`\`bash curl "http://localhost:3000/api/feedback/summary" \`\`\` **Example response:** \`\`\`json { "totalEntries": 10, "averageRating": 4.2, "ratingDistribution": { "1": 0, "2": 1, "3": 1, "4": 3, "5": 5 }, "courses": [ { "courseSlug": "knife-skills", "totalEntries": 5, "averageRating": 4.4 }, { "courseSlug": "bread-baking", "totalEntries": 4, "averageRating": 4.0 }, { "courseSlug": "pasta-from-scratch", "totalEntries": 1, "averageRating": 4.0 } ] } \`\`\` ## Schema ### Feedback | Field | Type | Description | |-------------|--------|------------------------------------------| | id | string | Unique identifier (e.g. "fb-001") | | courseSlug | string | Slug of the course | | lessonSlug | string | Slug of the lesson | | rating | number | Integer from 1 to 5 | | comment | string | Feedback text | | author | string | Name of the person | | createdAt | string | ISO 8601 timestamp | ## Workflows ### Investigate low-rated feedback for a course 1. \`GET /api/feedback/summary?courseSlug=knife-skills\`: check the average rating and total entries 2. \`GET /api/feedback?courseSlug=knife-skills&minRating=1\`: pull all entries for the course, then identify the low-rated ones 3. \`GET /api/feedback/fb-003\`: get the full details on a specific entry ### Submit and verify new feedback 1. \`POST /api/feedback\`: submit the feedback entry with all required fields 2. \`GET /api/feedback/:id\`: fetch the newly created entry using the \`id\` from the POST response 3. \`GET /api/feedback/summary?courseSlug=bread-baking\`: check updated stats for the course ### Compare feedback across courses 1. \`GET /api/feedback/summary\`: get aggregate stats for all courses 2. \`GET /api/feedback?courseSlug=knife-skills\`: pull all feedback for the top-rated course 3. \`GET /api/feedback?courseSlug=bread-baking\`: pull all feedback for comparison `; export async function GET() { return new NextResponse(llmsFullTxt, { headers: { "Content-Type": "text/plain; charset=utf-8", }, }); } ``` The longer response is expected because `llms-full.txt` includes every endpoint. It uses the same `text/plain` content type as `llms.txt` with a broader scope. \*\*Note: Index vs. full: two access patterns\*\* Use `llms.txt` as an index for selective fetching and `llms-full.txt` for the complete documentation. Offering both supports either access pattern. ## Add markdown access to the docs Vercel serves all their docs pages as `.md` too. If you can read `/docs/functions`, you can also read `/docs/functions.md`. This pattern makes it easy for agents to request documentation in a format they parse well. We'll do the same thing. The starter already has `app/api/docs.md/route.ts` with a TODO stub. Open it and fill in the full API documentation. Replace the placeholder in `app/api/docs.md/route.ts`: ```ts title="app/api/docs.md/route.ts" import { NextResponse } from "next/server"; const docs = `# Feedback API Base URL: \`/api/feedback\` ## Endpoints ### List feedback \`\`\` GET /api/feedback \`\`\` Returns all feedback entries. Supports optional query parameters for filtering. **Query parameters:** | Parameter | Type | Description | |--------------|--------|--------------------------------------| | courseSlug | string | Filter by course slug | | lessonSlug | string | Filter by lesson slug | | minRating | number | Only return entries rated >= value | **Example request:** \`\`\`bash curl "http://localhost:3000/api/feedback?courseSlug=knife-skills" \`\`\` **Example response:** \`\`\`json [ { "id": "fb-001", "courseSlug": "knife-skills", "lessonSlug": "the-claw-grip", "rating": 5, "comment": "Finally understand why my onion cuts were uneven. The claw grip changed everything.", "author": "Priya Sharma", "createdAt": "2026-03-01T10:30:00Z" } ] \`\`\` ### Get feedback by ID \`\`\` GET /api/feedback/:id \`\`\` Returns a single feedback entry. **Example request:** \`\`\`bash curl "http://localhost:3000/api/feedback/fb-001" \`\`\` **Example response:** \`\`\`json { "id": "fb-001", "courseSlug": "knife-skills", "lessonSlug": "the-claw-grip", "rating": 5, "comment": "Finally understand why my onion cuts were uneven. The claw grip changed everything.", "author": "Priya Sharma", "createdAt": "2026-03-01T10:30:00Z" } \`\`\` **Error response (404):** \`\`\`json { "error": "Feedback with id \\"fb-999\\" not found" } \`\`\` ### Submit feedback \`\`\` POST /api/feedback \`\`\` Creates a new feedback entry. The \`id\` and \`createdAt\` fields are generated automatically. **Request body (JSON):** | Field | Type | Required | Description | |-------------|--------|----------|---------------------------------| | courseSlug | string | yes | Slug of the course | | lessonSlug | string | yes | Slug of the lesson | | rating | number | yes | Rating from 1 to 5 | | comment | string | yes | Feedback text | | author | string | yes | Name of the person | **Example request:** \`\`\`bash curl -X POST "http://localhost:3000/api/feedback" \\ -H "Content-Type: application/json" \\ -d '{ "courseSlug": "bread-baking", "lessonSlug": "scoring-dough", "rating": 5, "comment": "The lame technique demo was incredibly helpful.", "author": "Alex Turner" }' \`\`\` **Example response (201):** \`\`\`json { "id": "fb-011", "courseSlug": "bread-baking", "lessonSlug": "scoring-dough", "rating": 5, "comment": "The lame technique demo was incredibly helpful.", "author": "Alex Turner", "createdAt": "2026-03-06T12:00:00Z" } \`\`\` **Error response (400), missing fields:** \`\`\`json { "error": "Missing required fields: courseSlug, lessonSlug, rating, comment, author" } \`\`\` **Error response (400), invalid rating:** \`\`\`json { "error": "Rating must be an integer between 1 and 5" } \`\`\` ### Get summary \`\`\` GET /api/feedback/summary \`\`\` Returns aggregate statistics across all feedback. Optionally filter by course. **Query parameters:** | Parameter | Type | Description | |-------------|--------|------------------------| | courseSlug | string | Filter by course slug | **Example request:** \`\`\`bash curl "http://localhost:3000/api/feedback/summary" \`\`\` **Example response:** \`\`\`json { "totalEntries": 10, "averageRating": 4.2, "ratingDistribution": { "1": 0, "2": 1, "3": 1, "4": 3, "5": 5 }, "courses": [ { "courseSlug": "knife-skills", "totalEntries": 5, "averageRating": 4.4 }, { "courseSlug": "bread-baking", "totalEntries": 4, "averageRating": 4.0 }, { "courseSlug": "pasta-from-scratch", "totalEntries": 1, "averageRating": 4.0 } ] } \`\`\` ## Schema ### Feedback | Field | Type | Description | |-------------|--------|------------------------------------------| | id | string | Unique identifier (e.g. "fb-001") | | courseSlug | string | Slug of the course | | lessonSlug | string | Slug of the lesson | | rating | number | Integer from 1 to 5 | | comment | string | Feedback text | | author | string | Name of the person | | createdAt | string | ISO 8601 timestamp | ## Workflows ### Investigate low-rated feedback for a course 1. \`GET /api/feedback/summary?courseSlug=knife-skills\`: check the average rating and total entries 2. \`GET /api/feedback?courseSlug=knife-skills&minRating=1\`: pull all entries for the course, then identify the low-rated ones 3. \`GET /api/feedback/fb-003\`: get the full details on a specific entry ### Submit and verify new feedback 1. \`POST /api/feedback\`: submit the feedback entry with all required fields 2. \`GET /api/feedback/:id\`: fetch the newly created entry using the \`id\` from the POST response 3. \`GET /api/feedback/summary?courseSlug=bread-baking\`: check updated stats for the course ### Compare feedback across courses 1. \`GET /api/feedback/summary\`: get aggregate stats for all courses 2. \`GET /api/feedback?courseSlug=knife-skills\`: pull all feedback for the top-rated course 3. \`GET /api/feedback?courseSlug=bread-baking\`: pull all feedback for comparison `; export async function GET() { return new NextResponse(docs, { headers: { "Content-Type": "text/markdown; charset=utf-8", }, }); } ``` The `.md` extension identifies the response as Markdown. The starter's `/api/docs` route remains a placeholder until the skill replaces it in Section 3, so `/api/docs.md` is the working endpoint for now. \*\*Note: Why write docs by hand if the skill will generate them?\*\* These hand-written docs provide the reference output used to evaluate the skill in Section 3. Once the skill works, it can maintain the generated route. ## Try It Start your dev server and test both new endpoints. Fetch the llms.txt file: ```bash curl http://localhost:3000/llms.txt ``` You should see the plain-text markdown with the project name, summary, and links to your API docs and endpoints (including the new `llms-full.txt` link). Now fetch the full documentation: ```bash curl http://localhost:3000/llms-full.txt ``` This should return the complete API documentation in a single response. It's the same content as `/api/docs.md` but with the project overview prepended. Now fetch the markdown docs: ```bash curl http://localhost:3000/api/docs.md ``` This should return the full API documentation in markdown. Verify the content types are correct: ```bash curl -I http://localhost:3000/llms.txt ``` Look for `Content-Type: text/plain; charset=utf-8` in the headers. ```bash curl -I http://localhost:3000/api/docs.md ``` Look for `Content-Type: text/markdown; charset=utf-8` in the headers. **Troubleshooting:** - If you get a 404, make sure the folder names are exactly `llms.txt`, `llms-full.txt`, and `docs.md` inside `app/` and `app/api/` respectively. The folder name becomes the URL path. - If the content type is wrong, double-check the `Content-Type` header string in your `NextResponse`. A typo like `text/plains` will silently serve the wrong type. ## Commit ```bash git add -A git commit -m "feat(docs): add llms.txt, llms-full.txt, and markdown docs access" ``` ## Done-When - [ ] Hitting `/llms.txt` returns a plain-text markdown file with H1, blockquote, description, and H2 sections linking to your endpoints - [ ] Hitting `/llms-full.txt` returns the complete API documentation in a single plain-text response - [ ] Hitting `/api/docs.md` returns the full API documentation in markdown - [ ] The `/llms.txt` and `/llms-full.txt` responses have `Content-Type: text/plain; charset=utf-8` - [ ] The `/api/docs.md` response has `Content-Type: text/markdown; charset=utf-8` - [ ] The llms.txt content includes links to `/api/docs`, `/api/docs.md`, and `/llms-full.txt` ## Solution The exercise above contains the complete implementations for these files: - **`app/llms.txt/route.ts`:** Returns the project index as `text/plain` with links to the docs and endpoints - **`app/llms-full.txt/route.ts`:** Returns the complete API documentation as `text/plain` - **`app/api/docs.md/route.ts`:** Returns the endpoint documentation as `text/markdown` without the project overview --- title: "Deploy to Vercel" description: "Push your changes to redeploy the feedback API so the llms.txt and markdown docs endpoints are live at your public URL." canonical_url: "https://vercel.com/academy/agent-friendly-apis/deploy-your-docs" md_url: "https://vercel.com/academy/agent-friendly-apis/deploy-your-docs.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-26T21:03:36.511Z" content_type: "lesson" course: "agent-friendly-apis" course_title: "Agent-Friendly APIs" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Deploy to Vercel # Deploy Your Docs The Vercel project from lesson 1.1 still contains the starter stubs. Push the API and documentation changes to update the deployment. This lesson publishes and verifies those changes. ## Outcome Push your changes to redeploy the feedback API so your llms.txt, docs, and feedback endpoints are live at your public URL. ## Fast Track 1. Commit your changes and push to your repo 2. Vercel redeploys automatically 3. Curl your live `/llms.txt`, `/api/docs.md`, and `/api/feedback` endpoints to confirm they work ## Push and redeploy Since your project is already connected to Vercel from the deploy button in lesson 1.1, every push to `main` triggers a new deployment automatically. Commit your latest changes and push: ```bash git add -A git commit -m "feat: add feedback API, docs, and llms.txt endpoints" git push ``` Open the Vercel dashboard to follow the build. When it finishes, the live URL will serve the new routes. ## Try It Replace the URL below with your deployment URL and verify each endpoint. Fetch the llms.txt file: ```bash curl https://your-project-name.vercel.app/llms.txt ``` You should see the plain-text markdown with the project name, summary blockquote, and links to your endpoints. Fetch the API docs via the markdown endpoint: ```bash curl https://your-project-name.vercel.app/api/docs.md ``` The full markdown documentation should come back with all the endpoint signatures, parameter tables, and examples. Fetch some feedback: ```bash curl https://your-project-name.vercel.app/api/feedback ``` You should see the JSON array of seed feedback entries. **Troubleshooting:** - If you get a 404 on `/llms.txt`, check that the folder is named exactly `llms.txt` inside `app/`. The deployment mirrors your local file structure. - If the deploy fails with a build error, check the Vercel build logs for TypeScript errors. The production build runs `next build`, which is stricter than the dev server about type checking. \*\*Warning: Data doesn't persist in production\*\* The feedback data lives in a JSON file bundled with the function. A warm instance may observe a file it wrote, but those writes are not durable or shared: a later request can run on another instance or a fresh deployment. This is fine for testing the seed data. For production writes, use Vercel Blob or a database. ## Commit You already committed and pushed in the exercise above. If you made any additional fixes during troubleshooting, commit those too: ```bash git add -A git commit -m "fix: resolve build issues for production deploy" git push ``` ## Done-When - [ ] Your latest code is pushed and Vercel has redeployed - [ ] Curling `/llms.txt` returns the plain-text llms.txt content - [ ] Curling `/api/docs.md` returns the full markdown API documentation - [ ] Curling `/api/feedback` returns the seed feedback data as JSON ## Solution No new code is required for this lesson. Push triggers a redeploy: ```bash git push ``` Vercel detects the Next.js App Router project without an additional configuration file. Confirm the deployment by testing the live routes rather than relying only on the local build. --- title: "Explore Real Skills" description: "Browse skills.sh to see how production skills structure their documentation, instructions, and reference files. Identify patterns you'll use when building your own skill in Section 3." canonical_url: "https://vercel.com/academy/agent-friendly-apis/explore-real-skills" md_url: "https://vercel.com/academy/agent-friendly-apis/explore-real-skills.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-26T21:03:36.528Z" content_type: "lesson" course: "agent-friendly-apis" course_title: "Agent-Friendly APIs" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Explore Real Skills # Explore Real Skills Before writing a skill, inspect a few that already work in production. Look for the files they include, the way they structure instructions, and the patterns that recur across projects. ## Outcome Explore real-world skills on skills.sh and identify the documentation patterns that make them effective. ## Fast Track 1. Browse the skills directory on skills.sh 2. Examine 2-3 skills and their file structure 3. Note the patterns that appear across multiple skills ## Browse the directory Head to [skills.sh](https://skills.sh) and scroll through the list. You'll see skills for everything from frontend design to database management to deployment workflows. Each one is a package that teaches an AI agent how to do something specific. Pick a skill that is relevant to your work. We'll inspect two examples in this lesson. ## Look at the structure Let's start with [**vercel-react-best-practices**](https://skills.sh/vercel-labs/agent-skills/vercel-react-best-practices). This skill teaches an agent to follow React best practices when building with Next.js and Vercel. Notice the current file structure: ``` SKILL.md AGENTS.md rules/ ├── async-parallel.md ├── bundle-barrel-imports.md └── ... ``` This skill uses a `SKILL.md` entry point and a `rules/` directory for detailed guidance. `AGENTS.md` provides a compiled version. Supporting folders are conventions chosen by each skill, not required names. The agent reads `SKILL.md` first. Inspect how it defines when and how to use the skill. ## What goes in SKILL.md Open the `SKILL.md` for vercel-react-best-practices or the skill you selected. Look for these elements: **A description with trigger phrases.** The opening paragraph tells the agent when the skill is relevant. Phrases such as "when building React components" or "when setting up a Next.js project" help it decide whether to load the skill. **Direct guidance.** The skill names the situations where each rule applies and links to detailed rule files for examples. **Prioritized criteria.** Categories and impact levels tell the agent which performance problems to address first. \*\*Note: Trigger phrases matter\*\* The description in SKILL.md controls when the agent activates the skill. A phrase such as "when creating React Server Components in Next.js App Router" gives it a specific request pattern to match. ## Look at a second skill Now inspect [**frontend-design**](https://skills.sh/anthropics/skills/frontend-design) or another relevant skill. Unlike the first example, `frontend-design` currently consists of a `SKILL.md` plus its license file; it does not use a references folder. Compare what each skill actually includes: - A clear description at the top with specific trigger phrases - Instructions written as concrete steps, not abstract advice - Supporting files only when the topic needs them - Criteria for evaluating the output, whether expressed as a checklist or direct guidance Reference files hold detailed documentation and examples that `SKILL.md` can load when needed. This keeps the entry point focused. ## Patterns worth stealing Several patterns recur across the examples: **Documentation uses a consistent structure.** The code blocks, tables, and explicit examples from the previous lesson appear throughout these skills. Each practice is stated directly and paired with examples where needed. **Instructions are imperative.** Direct commands tell the agent which action to take and what to verify. **Supporting detail can stay separate.** Larger skills may place detailed material in folders such as `rules/` or `references/`. Smaller skills may need only `SKILL.md`. **Evaluation criteria close the loop.** Good skills tell the agent what quality looks like and how to decide when the work is done. \*\*Note: Skills are just structured knowledge\*\* A skill is a folder of Markdown files organized so an agent can load the instructions and supporting detail it needs. Clear structure also helps human maintainers. ## Try It Find a skill on skills.sh that's relevant to your work. Read through its SKILL.md and answer these questions: - What trigger phrases does the description use? - How many steps are in the instructions? - Does it have a quality checklist at the end? - Does it use supporting files, and how are they organized? Answering these questions gives you a reference point for the skill you'll build next. Next, you'll build the same structure for the documentation generator. ## Commit No code changes to commit. ## Done-When - [ ] You've browsed at least 3 skills on skills.sh - [ ] You can describe the required `SKILL.md` and optional supporting files - [ ] You can identify trigger phrases in a skill's description - [ ] You've noted the pattern of imperative instructions with quality checklists - [ ] You can explain why reference files are kept separate from SKILL.md ## Solution No code solution for this lesson. The patterns you identified here inform the skill you'll build in Section 3. --- title: "Anatomy of a Skill" description: "Learn the structure of a Claude Code skill including the SKILL.md file, YAML frontmatter, the progressive disclosure system, and folder conventions for references and scripts." canonical_url: "https://vercel.com/academy/agent-friendly-apis/anatomy-of-a-skill" md_url: "https://vercel.com/academy/agent-friendly-apis/anatomy-of-a-skill.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-26T21:03:36.559Z" content_type: "lesson" course: "agent-friendly-apis" course_title: "Agent-Friendly APIs" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Anatomy of a Skill # Anatomy of a Skill The documentation from Section 2 works, but every API change requires someone to update the strings and verify them again. A skill can automate that work. A Claude Code skill packages instructions for a specific task in a folder. It makes the same process and domain knowledge available whenever the task comes up. You'll build a skill that reads the API code and generates Markdown documentation. ## Outcome Understand the file structure, frontmatter format, and progressive disclosure system of Claude Code skills. ## Fast Track 1. A skill is a folder with a `SKILL.md` file. That file is the entire skill definition. 2. YAML frontmatter in `SKILL.md` decides when the skill loads. The `description` field contains trigger phrases Claude matches against. 3. Files in `references/` load on demand, giving Claude deeper context without bloating the initial token cost. ## The folder A skill is a folder containing at minimum one file: `SKILL.md`. The folder name must be kebab-case. No spaces, no underscores, no capitals. ``` api-docs-generator/ ├── SKILL.md # Required: the main instruction file └── references/ # Optional: supporting docs Claude can pull in └── doc-patterns.md ``` A skill needs no package.json, build step, or runtime. Claude reads the structured Markdown directly. The folder can also include `scripts/` for executable code and `assets/` for templates, but we won't need those for this skill. ## SKILL.md The `SKILL.md` file has two parts: YAML frontmatter and the instructions body. **Frontmatter** is how Claude decides whether to load the skill. It's always read, even when the skill isn't active: ```yaml --- name: api-docs-generator description: Generates agent-friendly markdown documentation for API routes. Use when user says "generate docs", "document this API", "create API documentation", or "make docs for my endpoints". --- ``` Two fields matter here: **`name`:** Use kebab-case and match the folder name. **`description`:** State what the skill does and when to use it. Include phrases a user might use when requesting the task so Claude can match the skill to the request. \*\*Warning: SKILL.md is case-sensitive\*\* The file must be exactly `SKILL.md`. Not `skill.md`, not `Skill.md`, not `SKILL.MD`. Claude won't find it otherwise. \*\*Note: What if Claude doesn't trigger your skill?\*\* Claude matches the user's message against the `description` field. If your skill never activates, the problem is almost always missing trigger phrases. Add the exact words your users would say: "generate docs", "document this API", "create API documentation". The more variations you include, the more reliably the skill fires. ## Progressive disclosure Skills load information in three stages: 1. **Frontmatter** (always loaded): Claude reads `name` and `description` to decide whether the skill is relevant. 2. **SKILL.md body** (loaded when relevant): Claude loads the full instructions after matching the skill to the task. 3. **Referenced files** (loaded on demand): Claude reads files such as `doc-patterns.md` when a step requires deeper context. Progressive disclosure keeps unnecessary detail out of the initial context. Full instructions and references load only when the task needs them. ## The references folder Our skill uses `doc-patterns.md` for parameter tables, curl examples, and error formatting. It belongs in `references/` because those formatting rules support the main workflow. In the SKILL.md body, we'll point to it: ```markdown Consult `references/doc-patterns.md` for the formatting rules. ``` Claude will read that file when it gets to that step. ## Where skills live For this course, put the project skill in Claude Code's project-scoped skills directory: ``` your-project/ ├── .claude/ │ └── skills/ │ └── api-docs-generator/ │ ├── SKILL.md │ └── references/ ├── app/ ├── data/ └── lib/ ``` You can also install skills in your user-scoped Claude Code directory, but `.claude/skills/` keeps this one with the project. Anyone who clones the repo gets the skill. A skill combines a `SKILL.md` file with optional references. The next lesson focuses on writing instructions specific enough for Claude to follow without guessing. ## Try It No code to run in this lesson. The folder structure comes together in 3.2 when you build the skill. ## Commit No code changes to commit. ## Done-When - [ ] You can explain the three levels of progressive disclosure (frontmatter, body, references) - [ ] You know that `SKILL.md` is case-sensitive and must be exactly that name - [ ] You can describe what the `description` field does and why trigger phrases matter - [ ] You understand the folder structure: `SKILL.md` at root, optional `references/`, `scripts/`, `assets/` ## Solution No code solution for this lesson. The structure you learned here is what you'll build in 3.2. --- title: "Build the Generator" description: "Create the complete skill by writing step-by-step instructions in SKILL.md and the documentation formatting patterns in the references folder." canonical_url: "https://vercel.com/academy/agent-friendly-apis/build-the-generator" md_url: "https://vercel.com/academy/agent-friendly-apis/build-the-generator.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-26T21:03:36.574Z" content_type: "lesson" course: "agent-friendly-apis" course_title: "Agent-Friendly APIs" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Build the Generator # Build the Generator The skill frontmatter tells Claude when the skill applies. The body defines what to do after it loads. You'll write that workflow and its formatting reference in this lesson. ## Outcome Write the SKILL.md body and the `references/doc-patterns.md` file for the API docs generator skill. ## Fast Track 1. Write step-by-step instructions in SKILL.md 2. Write formatting rules in `references/doc-patterns.md` 3. Add a quality checklist to SKILL.md ## Hands-on exercise The starter already has `.claude/skills/api-docs-generator/SKILL.md` and `.claude/skills/api-docs-generator/references/doc-patterns.md` with TODO stubs. Open them up and fill in the real content. ### SKILL.md instructions The body of `SKILL.md` should walk Claude through a five-step process: **Step 1: Discover API routes.** Tell Claude to search the project for route handler files. Be specific about the glob pattern: ```markdown Search the project for all route handler files: Glob for app/api/**/route.ts and app/api/**/route.js. Exclude app/api/docs/** and app/api/docs.md/** so the generator documents the product API without documenting its own output routes. ``` Tell Claude to list the discovered routes and ask the user to confirm before proceeding. This lets the user verify the scope before the skill modifies files. **Step 2: Analyze each route.** For each route file, tell Claude what to extract: - HTTP methods exported (GET, POST, etc.) - URL path (derived from the file path) - Query parameters (look for `searchParams.get()` calls) - Request body shape (look for `request.json()` destructuring) - Response shapes (look for `NextResponse.json()` calls) - Error responses (look for non-200 status codes) - Validation rules (conditionals that return errors) Name the code patterns Claude should inspect so the output does not depend on guesswork. **Step 3: Read the types.** Tell Claude to find the TypeScript types referenced by the route handlers and use them to build the schema table. **Step 4: Generate the markdown.** Provide the document structure as a template. Reference the formatting rules: ```markdown Consult `references/doc-patterns.md` for the formatting rules. ``` **Step 5: Write the file.** Tell Claude to confirm with the user, then save the output to `app/api/docs/route.ts`. In the next lesson, the skill will replace the starter stub with a route handler that serves the generated Markdown. ### The quality checklist End the SKILL.md with a checklist that Claude should verify before finishing: ```markdown ## Quality checklist - [ ] Every endpoint has at least one example request (curl) and response (JSON) - [ ] Every error case is documented with its status code - [ ] Query parameters and request body fields list their types and whether they are required - [ ] The schema section matches the actual TypeScript types - [ ] The markdown renders correctly (no broken tables or unclosed code blocks) ``` The checklist gives Claude concrete criteria for reviewing its output. ### references/doc-patterns.md This file contains the formatting rules we established in Section 2. Write it as guidance that Claude can follow when generating docs: - Why agents need structured docs (not prose) - Endpoint signatures in code blocks, not inline - Parameters as tables with name, type, required, description - Curl examples with real values - Complete JSON responses (no truncation) - Every error case documented separately - Schema tables at the end - Workflow examples showing how endpoints chain together Include anti-patterns too. Tell Claude what not to do: no prose-only endpoint descriptions, no `...` in responses, no placeholder values. \*\*Note: Vague instructions produce vague output\*\* Compare these two instructions: **Too vague:** "Document the endpoints." **Specific enough to work:** "For each endpoint, write a curl example using real values from the seed data. Include the full URL, all required headers, and the request body for POST requests." If the output is vague, make the existing instruction more specific. \*\*Warning: Don't put everything in SKILL.md\*\* The formatting rules belong in `references/` because they're supporting detail. If you put everything in the main file, the skill loads all of it into context immediately. Progressive disclosure keeps the initial load light. \*\*Note: Troubleshooting: Claude skips a step\*\* If Claude skips Step 3, replace "Read the types" with a concrete action: "Find the TypeScript file imported by the route handler, extract every exported interface, and list each field with its type." Apply the same approach to any skipped step. ## Try It After writing both files, verify the structure: ```bash ls .claude/skills/api-docs-generator/ ``` ``` SKILL.md references/ ``` ```bash ls .claude/skills/api-docs-generator/references/ ``` ``` doc-patterns.md ``` Open `SKILL.md` and read through it. Does each step tell Claude exactly what to do? Could you follow the instructions yourself and produce correct docs? If not, the instructions need more detail. ## Commit ```bash git add -A && git commit -m "feat(skill): write SKILL.md instructions and doc-patterns reference" ``` ## Done-When - [ ] `SKILL.md` has valid frontmatter with `name` and `description` (including trigger phrases) - [ ] SKILL.md body has 5 clear steps: discover, analyze, read types, generate, write - [ ] Step 2 specifies exactly what to extract from each route file - [ ] Step 4 references `references/doc-patterns.md` - [ ] Quality checklist has at least 5 verification items - [ ] `references/doc-patterns.md` covers formatting rules and anti-patterns ## Solution ```markdown title=".claude/skills/api-docs-generator/SKILL.md" --- name: api-docs-generator description: Generates agent-friendly markdown documentation for API routes. Use when user says "generate docs", "document this API", "create API documentation", or "make docs for my endpoints". --- # API Docs Generator Generate comprehensive, agent-friendly markdown documentation for API route handlers in a Next.js App Router project. ## Instructions ### Step 1: Discover API routes Search the project for all route handler files: Glob for app/api/**/route.ts and app/api/**/route.js. Exclude app/api/docs/** and app/api/docs.md/** so the skill does not document its own output routes. List all discovered routes and confirm with the user before proceeding. ### Step 2: Analyze each route For each route file, extract: - HTTP methods exported (GET, POST, PUT, DELETE, PATCH) - URL path (derived from the file path) - Query parameters (look for `searchParams.get()` calls) - Request body shape (look for `request.json()` destructuring) - Response shapes (look for `NextResponse.json()` calls) - Error responses (look for non-200 status codes) - Validation rules (look for conditionals that return error responses) ### Step 3: Read the types Look for a types file referenced by the route handlers. Extract the TypeScript interfaces and use them to build the schema table in the docs. ### Step 4: Generate the markdown Produce a single markdown document following this structure. Consult `references/doc-patterns.md` for the formatting rules. ### Step 5: Write the file Save the generated markdown to `app/api/docs/route.ts` as a route handler that returns the markdown string with `Content-Type: text/markdown; charset=utf-8`. Ask the user to confirm the output location before writing. ## Quality checklist Before finishing, verify the generated docs include: - [ ] Every endpoint has at least one example request (curl) and response (JSON) - [ ] Every error case is documented with its status code - [ ] Query parameters and request body fields list their types and whether they are required - [ ] The schema section matches the actual TypeScript types - [ ] The markdown renders correctly (no broken tables or unclosed code blocks) - [ ] At least 2 workflow examples showing how endpoints chain together for real tasks ``` ```markdown title=".claude/skills/api-docs-generator/references/doc-patterns.md" # Documentation Patterns for Agent-Friendly APIs These patterns make API documentation easy for AI agents to parse and use correctly. ## Why agents need different docs Human developers skim docs, infer patterns, and fill in gaps from experience. Agents read docs literally. If the docs are ambiguous, the agent will guess wrong. If an error case is undocumented, the agent won't know how to recover. Agent-friendly docs are explicit, structured, and example-heavy. ## Formatting rules ### Endpoints Always include the HTTP method and full path on their own line in a code block. Not inline. Agents parse the code block reliably. Prose descriptions of URLs are error-prone. ### Parameters as tables Use markdown tables for query parameters and request body fields. Always include: parameter name, type, whether it's required or optional, and a short description. Agents parse tables into structured data. Bullet lists of parameters are harder to extract reliably. ### Example requests with curl Use curl for all example requests. Include the full URL, all required headers, and the request body for POST/PUT/PATCH. Agents can execute curl commands directly. ### Example responses as JSON blocks Show the complete response body, not a truncated version. Include all fields, realistic values, and the correct JSON structure. ### Every error case gets its own block Document each error response separately with the HTTP status code, the condition that triggers it, and the exact response body. ### Schema section End the docs with a schema section that lists every data type as a table with field name, type, and description. ### Workflow examples End the docs with a Workflows section after the schema. Each workflow is a numbered sequence of API calls that accomplish a real task. Include a descriptive name, numbered steps with method + path in inline code, and a short explanation of why each call is made. Include at least 2 workflows covering common multi-step tasks. ## Anti-patterns to avoid - Prose-only descriptions of endpoints (no code blocks with method + path) - Truncated responses with "..." or "and so on" - Missing error cases (agents will not know how to recover) - Generic placeholder data in examples ("string", "number" instead of real values) - Undocumented query parameters (agents won't discover them by experimentation) ``` --- title: "Run and Evaluate" description: "Run the API docs generator skill for the first time in Claude Code. Watch it discover routes, generate markdown docs, and create the /api/docs endpoint. Then evaluate the output against the quality checklist and test the generated curl examples." canonical_url: "https://vercel.com/academy/agent-friendly-apis/run-and-evaluate" md_url: "https://vercel.com/academy/agent-friendly-apis/run-and-evaluate.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-26T21:03:36.596Z" content_type: "lesson" course: "agent-friendly-apis" course_title: "Agent-Friendly APIs" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Run and Evaluate # Run and Evaluate The instructions are ready for a first run. Evaluate the skill by the documentation it produces. A first run reveals instructions that seemed clear to you but left room for Claude to interpret them differently. ## Outcome Invoke the API docs generator skill in Claude Code and evaluate the generated documentation against the quality checklist. ## Fast Track 1. Trigger the skill in Claude Code with "generate docs for my API" 2. Watch Claude discover routes, analyze them, and generate the docs 3. Run the quality checklist and test the generated curl examples ## Hands-on exercise ### Invoking the skill Open Claude Code in your project directory. Run `/skills` and confirm that `api-docs-generator` appears from `.claude/skills/api-docs-generator/`. Invoke it directly first: ``` /api-docs-generator ``` Then try triggering it with one of the phrases from the description field: ``` Generate docs for my API ``` Claude should load the skill and start working through the steps. Watch what happens: 1. Does it find the three feedback route files while excluding `/api/docs` and `/api/docs.md`? 2. Does it list them and ask you to confirm? 3. Does it analyze each route correctly? 4. Does it create the `app/api/docs/route.ts` file? The starter's `/api/docs` route contains placeholder content. The skill should read the route handlers, generate the Markdown, and replace that stub with a working documentation route. \*\*Note: What the skill creates\*\* When the skill finishes, `app/api/docs/route.ts` should contain a route handler that returns the API documentation as Markdown. Claude creates it by following your SKILL.md instructions. ### Evaluating the output Once Claude generates the docs, run through the quality checklist from the SKILL.md: - [ ] Every endpoint has at least one example request (curl) and response (JSON) - [ ] Every error case is documented with its status code - [ ] Query parameters and request body fields list their types and whether they are required - [ ] The schema section matches the actual TypeScript types - [ ] The markdown renders correctly (no broken tables or unclosed code blocks) Also check against what you built by hand in Section 2: - Are the curl examples using real seed data values? - Do the error messages match what the code actually returns? - Is the schema complete with all seven fields? Use the first run to identify which requirements Claude followed and where the output drifted. ### Testing the generated docs Start your dev server and hit the generated endpoint: ```bash curl http://localhost:3000/api/docs ``` The output should be structured markdown with all four endpoints documented. Now pick a few curl examples from the generated docs and run them: ```bash # From the docs: list feedback filtered by course curl "http://localhost:3000/api/feedback?courseSlug=knife-skills" # From the docs: submit new feedback curl -X POST "http://localhost:3000/api/feedback" \ -H "Content-Type: application/json" \ -d '{ "courseSlug": "bread-baking", "lessonSlug": "scoring-dough", "rating": 5, "comment": "The lame technique demo was incredibly helpful.", "author": "Alex Turner" }' # From the docs: get summary curl "http://localhost:3000/api/feedback/summary" ``` Every example should produce output that matches the documented response shapes. If an example fails or returns something unexpected, make a note. We'll fix it in the next lesson. ### Common issues **The skill doesn't appear.** Run `/skills` to verify discovery and check that the file is exactly `.claude/skills/api-docs-generator/SKILL.md`. If direct invocation works but a natural-language request does not, add that request wording to the description. **Claude skips an endpoint.** Step 1 might not be finding all the route files. Check the glob pattern. Make sure it covers nested routes like `app/api/feedback/[id]/route.ts`. **Examples use placeholder data.** The instructions in Step 4 might not be specific enough about using real values. Add explicit guidance: "Use realistic values from the project's seed data, not placeholders like 'string' or 'example'." **Error cases are missing.** Step 2 might not be specific enough about what to extract. Make sure it mentions looking for non-200 status codes and conditionals that return error responses. **Schema doesn't match the types.** Step 3 might not be pointing Claude to the right file. Be explicit: "Look for TypeScript interfaces imported by the route handlers, typically in `lib/types.ts`." \*\*Note: Take notes on what needs fixing\*\* Record each issue you find, such as a missing error case, incorrect curl syntax, truncated response, or placeholder value. The next lesson uses this list to refine the instructions. ## Commit ```bash git add -A && git commit -m "feat(skill): first invocation of API docs generator skill" ``` ## Done-When - [ ] The skill triggers when you say "generate docs" in Claude Code - [ ] Claude discovers the three feedback route files and excludes the documentation routes - [ ] The skill creates `app/api/docs/route.ts` (you didn't write it by hand) - [ ] `curl http://localhost:3000/api/docs` returns structured markdown - [ ] You've run the quality checklist and noted any gaps - [ ] You've tested at least three curl examples from the generated docs --- title: "Iterate and Ship" description: "Update the skill instructions based on what you found during evaluation. Re-run the skill, compare output, and repeat until the generated docs pass the quality checklist. Verify the final /api/docs endpoint works end-to-end." canonical_url: "https://vercel.com/academy/agent-friendly-apis/iterate-and-ship" md_url: "https://vercel.com/academy/agent-friendly-apis/iterate-and-ship.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-26T21:03:36.613Z" content_type: "lesson" course: "agent-friendly-apis" course_title: "Agent-Friendly APIs" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Iterate and Ship # Iterate and Ship Your first run may have exposed a missing error case, an incomplete schema, or vague parameter descriptions. Update the relevant instruction and run the skill again. Repeat that loop until the output passes the quality checklist. ## Outcome Refine the skill instructions based on evaluation results, re-run the skill, and verify the final documentation end-to-end. ## Fast Track 1. Update SKILL.md or `references/doc-patterns.md` based on issues found in 3.3 2. Re-run the skill and compare output 3. Verify the final `/api/docs` endpoint with curl ## Hands-on exercise ### Fixing the instructions Go back to the notes you took in the last lesson. For each issue, decide where the fix belongs: **Fix it in SKILL.md** if the problem is about what Claude does or what order it does it in. Skipped a step? Make the instruction more specific. Wrong glob pattern? Update the path. Forgot to confirm with the user? Add that check. **Fix it in `references/doc-patterns.md`** if the problem is about formatting. Tables missing a column? Add the column to the formatting rules. Curl examples using `localhost` instead of the full URL? Add that as an explicit requirement. Responses truncated with `...`? Call it out in the anti-patterns section. Keep process instructions in SKILL.md and formatting rules in the reference file so each remains easy to maintain. \*\*Warning: Resist the urge to add more steps\*\* Prefer making an existing instruction more specific. For example, replace "Document the endpoints" with "For each endpoint, write a curl example using real values from the seed data." ### Re-running the skill After updating the files, invoke the skill again: ``` /api-docs-generator ``` Claude will re-read the updated SKILL.md and reference files. Compare this output to the first run: - Did the issues you noted get fixed? - Did the fixes introduce any new problems? - Does the quality checklist pass now? Expect to run the skill more than once. If several revisions do not resolve the same problems, inspect the workflow order instead of continuing to add wording. \*\*Note: Iterate on a single task\*\* Keep re-running the skill on the same project until the output is consistently good. Resist the urge to test on multiple projects before the skill works reliably on one. ### Verifying end-to-end Once the output looks solid, verify the full pipeline. Start with the docs endpoint: ```bash curl http://localhost:3000/api/docs ``` Read the full response and check every endpoint, error case, parameter, and response shape. Then run every curl example in the generated docs. ```bash # List all feedback curl "http://localhost:3000/api/feedback" # Filter by course curl "http://localhost:3000/api/feedback?courseSlug=knife-skills" # Submit new feedback curl -X POST "http://localhost:3000/api/feedback" \ -H "Content-Type: application/json" \ -d '{ "courseSlug": "bread-baking", "lessonSlug": "scoring-dough", "rating": 5, "comment": "The lame technique demo was incredibly helpful.", "author": "Alex Turner" }' # Get a single entry curl "http://localhost:3000/api/feedback/fb-001" # Get summary stats curl "http://localhost:3000/api/feedback/summary" ``` Every example should produce output that matches the documented response shapes. If any example fails or returns an unexpected shape, either the docs or the API has a bug. Fix it and re-run. ### Skills are living documents The skill must evolve with the API. New endpoints, validation rules, and renamed fields may require updated instructions. Update the Markdown instructions, run the skill again, and check the output. No build or deployment step is required for the skill itself. Treat your skill like you'd treat a good test suite. When the code changes, the skill should change with it. \*\*Note: Commit your skill to the repo\*\* The skill folder lives in `.claude/skills/`, so it travels with the codebase and its version history. When a pull request changes an API route, reviewers can check whether the documentation skill was run again. ### The final project Here's what the complete project looks like: ``` your-project/ ├── .claude/ │ └── skills/ │ └── api-docs-generator/ # The skill │ ├── SKILL.md # Refined instructions │ └── references/ │ └── doc-patterns.md ├── app/ │ ├── llms.txt/route.ts # llms.txt index │ ├── llms-full.txt/route.ts # Complete docs in one response │ └── api/ │ ├── docs/route.ts # Generated by the skill │ ├── docs.md/route.ts # Markdown docs endpoint │ └── feedback/ │ ├── route.ts # GET (list + filter) and POST │ ├── [id]/route.ts # GET single entry │ └── summary/route.ts # GET aggregate stats ├── data/ │ └── feedback.json # Seed data └── lib/ ├── data.ts # Read/write utility └── types.ts # Feedback interface ``` ## Commit ```bash git add -A && git commit -m "feat(skill): finalize and test API docs generator skill" ``` ## Done-When - [ ] SKILL.md or `references/doc-patterns.md` has been updated based on evaluation results - [ ] The skill has been re-run at least once after updates - [ ] Generated docs pass all five items on the quality checklist - [ ] Every curl example from the generated docs produces matching output - [ ] `curl http://localhost:3000/api/docs` returns complete, structured markdown - [ ] The skill folder is committed to the repo ## Solution The `complete/` repo contains the skill from lesson 3.2 with the refinements from this lesson. Your SKILL.md should reflect the gaps you observed during testing. The skill is ready when it consistently produces output that passes the checklist. Repeated failures in the same area indicate an instruction that needs more specificity. You built a feedback API, documented it for agents, and packaged the documentation process in a skill. When the API changes, run the skill again and review the generated docs. --- title: "Svelte on Vercel" description: "Build production-ready SvelteKit applications on Vercel. Learn deployment, AI integration, workflows, and performance optimization." canonical_url: "https://vercel.com/academy/svelte-on-vercel" md_url: "https://vercel.com/academy/svelte-on-vercel.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-09-22T04:50:23.314Z" content_type: "course" lessons: 15 estimated_time: lesson_urls: - "https://vercel.com/academy/svelte-on-vercel/deploy-svelte-to-vercel.md" - "https://vercel.com/academy/svelte-on-vercel/environment-variables.md" - "https://vercel.com/academy/svelte-on-vercel/preview-deployments.md" - "https://vercel.com/academy/svelte-on-vercel/runtime-selection.md" - "https://vercel.com/academy/svelte-on-vercel/streaming-chat.md" - "https://vercel.com/academy/svelte-on-vercel/tools-and-agents.md" - "https://vercel.com/academy/svelte-on-vercel/svelte-structured-output.md" - "https://vercel.com/academy/svelte-on-vercel/fallbacks-and-tracking.md" - "https://vercel.com/academy/svelte-on-vercel/durable-tasks.md" - "https://vercel.com/academy/svelte-on-vercel/multi-step-workflows.md" - "https://vercel.com/academy/svelte-on-vercel/workflow-error-handling.md" - "https://vercel.com/academy/svelte-on-vercel/isr.md" - "https://vercel.com/academy/svelte-on-vercel/svelte-observability.md" - "https://vercel.com/academy/svelte-on-vercel/performance.md" - "https://vercel.com/academy/svelte-on-vercel/svelte-conclusion.md" --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Svelte on Vercel It's 5:47am and your phone buzzes. You want to keep sleeping, but you know that sound means only one thing: six inches of fresh powder at Grand Targhee overnight, temperature sitting at 18°F. Exactly the conditions you told the app to let you know about. You roll out of bed, pour coffee directly into your throat, and drive straight to the mountain. That alert didn't come from a weather app. It's your app. A SvelteKit app that streams AI chat responses, parses natural language into structured alert rules, evaluates conditions against live weather data in a background workflow, and serves the whole thing from Vercel. That's the project behind this course: **Ski Alerts**. A real app with real deployment problems to solve. ## What you'll actually build Throughout this course, you'll build Ski Alerts from first deploy to production-ready: **Progressive deployment pipeline:** - Configure and deploy a SvelteKit app to Vercel - Set up environment variables across development, preview, and production - Implement preview deployments for team collaboration **AI-powered features:** - Build streaming chat interfaces with AI SDK v6 - Create tools and multi-step agents - Extract structured data with Valibot schemas **Background processing:** - Build durable workflows with the Workflow SDK - Run parallel steps and schedule re-checks with sleep - Handle errors with FatalError, RetryableError, and exponential backoff **Production hardening:** - Configure ISR for optimal caching - Set up observability and logging - Optimize performance for real users ## Prerequisites - Familiarity with SvelteKit basics and the [official tutorial](https://svelte.dev/tutorial/kit/introducing-sveltekit) - Node.js 24+ and npm (or pnpm) installed - A Vercel account (free tier works) ## Course sections ### Section 1: Deployment Foundations Get your SvelteKit app running on Vercel with proper configuration, environment management, and preview deployments. ### Section 2: AI Gateway Integrate AI features using the AI SDK v6: streaming responses, tool use, structured outputs, model fallbacks, and usage tracking. ### Section 3: Workflows Build durable workflows with the Workflow SDK: parallel steps, automatic retries, sleep-based scheduling, and error classification. ### Section 4: Production Configure ISR, structured logging, response caching, and parallel data fetching. --- title: "Deploy to Vercel" description: "Configure and deploy a SvelteKit application to Vercel with the correct adapter settings and build configuration." canonical_url: "https://vercel.com/academy/svelte-on-vercel/deploy-svelte-to-vercel" md_url: "https://vercel.com/academy/svelte-on-vercel/deploy-svelte-to-vercel.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-08T23:18:27.608Z" content_type: "lesson" course: "svelte-on-vercel" course_title: "Svelte on Vercel" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Deploy to Vercel # Deploy a SvelteKit App to Vercel Your SvelteKit app runs on `localhost:5173`, but nobody can receive a powder alert from your laptop. Deploy it to Vercel so it has a public URL and a production runtime. ## Outcome Fork the ski-alerts starter, run it locally, and deploy it to Vercel. ## Fast Track 1. Fork and clone the starter repo from GitHub 2. Install dependencies and verify the app runs locally 3. Import the project into Vercel and verify your `.vercel.app` URL ## How You'd Start from Scratch If you were building a brand new SvelteKit app, you'd run: ```bash npx sv create my-app ``` That scaffolds a project with TypeScript, ESLint, and your choice of styling. For this course, we've already done that and added the UI components, resort data, weather service, and alert schemas you'll build on. The starter is your starting line so you can focus on the Vercel-specific parts. ## Why adapter-vercel? SvelteKit uses adapters to transform your app for different deployment targets. The default `adapter-auto` detects Vercel automatically, but `adapter-vercel` gives you explicit control over Vercel-specific features like ISR and runtime selection that you'll use throughout this course. The starter app already has this wired up: ```javascript title="svelte.config.js" import adapter from '@sveltejs/adapter-vercel'; /** @type {import('@sveltejs/kit').Config} */ const config = { kit: { adapter: adapter() } }; export default config; ``` And the dependency is in `package.json`: ```json title="package.json" {3} { "devDependencies": { "@sveltejs/adapter-vercel": "^6.3.3", "@sveltejs/kit": "^2.50.1" } } ``` ## Hands-on Exercise 1.1 Get the ski-alerts starter running locally and deployed to Vercel: **Requirements:** 1. Fork the [ski-alerts starter repo](https://github.com/vercel-labs/ski-alerts) on GitHub 2. Clone your fork locally and install dependencies 3. Import the project into Vercel from the dashboard **Implementation hints:** - Fork the repo first so you own the copy. You'll push changes to it throughout the course - The app loads the conditions dashboard even without an API key because the weather service uses the free Open-Meteo API - The chat feature won't work yet (you'll add the API key in the next lesson) ## Try It 1. **Fork and clone:** Go to [github.com/vercel-labs/ski-alerts](https://github.com/vercel-labs/ski-alerts) and click **Fork**. Then clone your fork: ```bash git clone https://github.com//ski-alerts.git cd ski-alerts npm install ``` 2. **Run locally to make sure it works:** ```bash npm run dev ``` Open `http://localhost:5173`. You should see the conditions dashboard with weather data for 5 ski resorts. 3. **Deploy to Vercel:** Go to [vercel.com/new](https://vercel.com/new) and click **Add New Project**. Select your `ski-alerts` fork from the list of repositories. Vercel detects SvelteKit automatically and configures the build settings. Click **Deploy**. 4. **Visit your production URL:** ``` https://ski-alerts-xxxxx.vercel.app ``` You should see the same conditions dashboard, now live on the internet. 5. **Verify the build output in the deployment log:** ``` $ vite build vite v7.3.1 building SSR bundle for production... ✓ 42 modules transformed. .svelte-kit/output/server/index.js 12.34 kB ✓ built in 1.2s ``` ## Done-When - [ ] You have a fork of ski-alerts in your GitHub account - [ ] The app runs locally on `localhost:5173` - [ ] Project is imported in the Vercel dashboard - [ ] Build completes without errors - [ ] Conditions dashboard loads at your `.vercel.app` URL with live weather data ## Solution The starter app is already configured correctly. The key pieces: **1. Adapter installed and configured:** ```javascript title="svelte.config.js" import adapter from '@sveltejs/adapter-vercel'; /** @type {import('@sveltejs/kit').Config} */ const config = { kit: { adapter: adapter() } }; export default config; ``` **2. Build pipeline:** ```javascript title="vite.config.ts" import tailwindcss from '@tailwindcss/vite'; import { sveltekit } from '@sveltejs/kit/vite'; import { defineConfig } from 'vite'; export default defineConfig({ plugins: [tailwindcss(), sveltekit()] }); ``` **3. Fork, clone, and deploy:** Fork the repo on GitHub, clone it, install dependencies, and import the project into Vercel from the dashboard. Vercel detects SvelteKit, runs `vite build`, and deploys your app. ```bash git clone https://github.com//ski-alerts.git cd ski-alerts npm install ``` ## Troubleshooting \*\*Warning: 404 after deploy\*\* Check that the Framework Preset is set to **SvelteKit** in your Vercel project settings under **Settings → General → Framework Preset**. If Vercel doesn't auto-detect it, the build output won't be structured correctly. \*\*Warning: Build fails with 'adapter not found'\*\* Make sure `@sveltejs/adapter-vercel` is in `devDependencies`, not `dependencies`. SvelteKit adapters are build-time tools. ## Advanced: Vercel CLI Deployments You can also deploy from the command line instead of the Vercel dashboard: ```bash # Install the Vercel CLI npm i -g vercel # Deploy (first time will prompt for project setup) vercel # Deploy to production vercel --prod ``` The CLI is useful for testing deployments before pushing to your main branch. --- title: "Environment Variables" description: "Manage environment variables across development, preview, and production scopes using the Vercel dashboard and vercel env pull." canonical_url: "https://vercel.com/academy/svelte-on-vercel/environment-variables" md_url: "https://vercel.com/academy/svelte-on-vercel/environment-variables.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-08T23:18:27.627Z" content_type: "lesson" course: "svelte-on-vercel" course_title: "Svelte on Vercel" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Environment Variables # Managing Environment Variables on Vercel The chat feature needs an API key to talk to the AI Gateway. We could hardcode it, but then it's sitting in your Git history forever. Vercel's environment variable system gives you scoped secrets (different values for development, preview, and production) without any of them touching source control. ## Outcome Configure the `AI_GATEWAY_API_KEY` across Vercel's three environment scopes and pull it locally with `vercel env pull`. ## Fast Track 1. Add `AI_GATEWAY_API_KEY` in the Vercel dashboard under **Settings → Environment Variables** 2. Run `vercel env pull` to sync the variable to your local `.env` file 3. Access it in server code with `$env/static/private` ## Vercel's Three Scopes Every environment variable on Vercel has one or more scopes: | Scope | When it's used | Example | | --------------- | ------------------------------------- | --------------------------------- | | **Production** | Deployments from your main branch | Live API key with billing alerts | | **Preview** | Deployments from other branches (PRs) | Shared team key for testing | | **Development** | Local dev via `vercel env pull` | Personal key with low rate limits | This matters. You might want a test API key for preview deployments and a production key for your main branch. Or you might use the same key everywhere. The point is you choose explicitly. ## Hands-on Exercise 1.2 Add the AI Gateway API key to your Vercel project and access it in server code: **Requirements:** 1. Add `AI_GATEWAY_API_KEY` in the Vercel dashboard with all three scopes enabled 2. Pull the variable locally with `vercel env pull` 3. Verify the variable is accessible in a SvelteKit server endpoint using `$env/static/private` **Implementation hints:** - Get an API key from the [AI Gateway settings](https://vercel.com/~/ai-gateway) in your Vercel dashboard - The `.env` file is already in `.gitignore`, so never commit it - SvelteKit offers both `$env/static/private` (inlined at build time) and `$env/dynamic/private` (loaded at runtime). The SvelteKit team recommends `$env/static/private` as the default since it enables better optimization, and that's what the ski-alerts app uses - The chat endpoint has a TODO comment showing where this variable will be used. Check `src/routes/api/chat/+server.ts` ## Try It 1. **Add the variable in Vercel:** - Go to your project → **Settings** → **Environment Variables** - Name: `AI_GATEWAY_API_KEY` - Value: your gateway key - Check all three scopes: Production, Preview, Development - Click **Save** 2. **Pull locally:** \*\*Note: First time using vercel env pull?\*\* If you haven't connected your local directory to the Vercel project yet, run `vercel link` first. The CLI needs to know which project to pull variables from. ```bash $ vercel env pull Downloading Development Environment Variables for project ski-alerts ✅ Created .env file ``` 3. **Verify the `.env` file exists:** ```bash $ cat .env # Created by Vercel CLI AI_GATEWAY_API_KEY="your-gateway-key" ``` 4. **Check that server code can access it:** The chat endpoint in the starter shows the pattern you'll use in Section 2: ```typescript title="src/routes/api/chat/+server.ts" {2} // You'll implement this in Section 2, but the import pattern is: import { AI_GATEWAY_API_KEY } from '$env/static/private'; // Access directly: AI_GATEWAY_API_KEY ``` ## Commit No code changes needed for this lesson. The environment variable lives in Vercel's dashboard and your local `.env` file (which is gitignored). ## Done-When - [ ] `AI_GATEWAY_API_KEY` appears in your Vercel project's Environment Variables settings - [ ] Running `vercel env pull` creates a `.env` file locally - [ ] The `.env` file contains your API key - [ ] The `.env` file is listed in `.gitignore` ## Solution **Step 1: Vercel Dashboard** Navigate to your project → Settings → Environment Variables. Add: | Key | Value | Scopes | | -------------------- | ------------------ | -------------------------------- | | `AI_GATEWAY_API_KEY` | `your-gateway-key` | Production, Preview, Development | **Step 2: Pull locally** ```bash vercel env pull ``` This creates `.env` in your project root with all Development-scoped variables. **Step 3: Access in SvelteKit** SvelteKit provides two ways to access environment variables: ```typescript // Static: inlined at build time (server-only) — recommended import { AI_GATEWAY_API_KEY } from '$env/static/private'; // Dynamic: loaded at runtime (server-only) import { env } from '$env/dynamic/private'; console.log(env.AI_GATEWAY_API_KEY); ``` Use `$env/static/private` as the default. It's the SvelteKit team's recommendation because the bundler can optimize the inlined values. That's what the ski-alerts app uses. Reach for `$env/dynamic/private` when you need to read values that change between serverless invocations or when you're building a library that shouldn't assume which variables exist. \*\*Warning: Never use public env for secrets\*\* SvelteKit also has `$env/dynamic/public` and `$env/static/public`. These are exposed to the browser. Only use `private` imports for API keys and secrets. ## Troubleshooting \*\*Warning: .env file is empty after pulling\*\* Check that you enabled the **Development** scope when you added the variable. Production-only variables won't appear in your local `.env` file. ## Advanced: Per-Scope Values You can set different values per scope. A common pattern: - **Development**: A personal API key with low rate limits - **Preview**: A shared team key for testing - **Production**: A production key with higher limits and billing alerts To set scope-specific values, uncheck "All Environments" in the dashboard and add the variable once per scope with different values. --- title: "Preview Deployments" description: "Use preview deployments to test changes before production and collaborate with your team on pull requests." canonical_url: "https://vercel.com/academy/svelte-on-vercel/preview-deployments" md_url: "https://vercel.com/academy/svelte-on-vercel/preview-deployments.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-08T23:18:27.643Z" content_type: "lesson" course: "svelte-on-vercel" course_title: "Svelte on Vercel" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Preview Deployments # Preview Deployments Pushing every change directly to production leaves no safe place to review it. Vercel gives each branch its own deployment, scoped environment variables, and a URL you can share with your team before merging. ## Outcome Create a preview deployment by pushing a branch and verify it runs with its own URL and environment. ## Fast Track 1. Create a feature branch and push it to GitHub 2. Open a pull request. Vercel deploys automatically and comments with the preview URL 3. Test the preview deployment and verify it has its own environment ## How Preview Deployments Work ``` main branch ──push──→ Production deployment (ski-alerts.vercel.app) │ feature branch ──push──→ Preview deployment (ski-alerts-git-feature-xyz.vercel.app) │ another branch ──push──→ Preview deployment (ski-alerts-git-another-abc.vercel.app) ``` Every non-production branch gets its own deployment. Preview deployments: - Use **Preview**-scoped environment variables (set in lesson 1.2) - Get a unique URL based on the branch name - Update on every push to that branch - Show up as GitHub PR checks with a direct link ## Hands-on Exercise 1.3 Create a preview deployment for a small change to the ski-alerts app: **Requirements:** 1. Create a new branch from `main` 2. Make a visible change (update the dashboard title or add a resort) 3. Push the branch and open a pull request on GitHub 4. Verify the preview deployment URL works **Implementation hints:** - A simple text change in `src/routes/+page.svelte` is enough to see the preview work - Vercel's GitHub integration automatically adds a comment to PRs with the preview URL - Preview deployments use Preview-scoped environment variables, so your API key works if you enabled that scope ## Try It 1. **Create a branch and make a change:** ```bash $ git checkout -b add-dashboard-subtitle ``` Edit `src/routes/+page.svelte` and change the subtitle text: ```svelte title="src/routes/+page.svelte" {3}

Current Conditions

Real-time weather for your favorite ski resorts

``` 2. **Push and open a PR:** ```bash $ git add src/routes/+page.svelte $ git commit -m "update dashboard subtitle" $ git push -u origin add-dashboard-subtitle ``` Open a pull request on GitHub. Within a minute, Vercel adds a comment with: ``` ✅ Preview deployment ready https://ski-alerts-git-add-dashboard-subtitle-yourteam.vercel.app ``` 3. **Visit the preview URL:** - Verify your subtitle change is visible - The conditions dashboard should load with live weather data - This deployment is completely separate from your production deployment 4. **Check the PR checks:** - The Vercel check shows green with a "Visit Preview" link - Click it to open the preview directly from the PR ## Wrap Up Once you've verified the preview deployment works, merge the PR on GitHub. The merge triggers a production deployment with your change. You can then delete the feature branch, and Vercel cleans up the preview deployment automatically. ## Done-When - [ ] Feature branch is pushed to GitHub - [ ] PR has a Vercel comment with a preview URL - [ ] Preview URL loads and shows your change - [ ] Production URL is unchanged (still shows original subtitle) ## Solution The full workflow: ```bash # Create branch git checkout -b add-dashboard-subtitle # Make a change to +page.svelte (update the subtitle) # Commit and push git add src/routes/+page.svelte git commit -m "update dashboard subtitle" git push -u origin add-dashboard-subtitle # Open PR on GitHub (or use gh CLI) gh pr create --title "Update dashboard subtitle" --body "Testing preview deployments" ``` After merging, the change deploys to production automatically. You can delete the branch and Vercel cleans up the preview deployment. \*\*Note: Preview URLs are stable per branch\*\* The preview URL for a branch stays the same across pushes. Share it with teammates and they'll always see the latest version of that branch. ## Troubleshooting \*\*Warning: No Vercel comment on your PR\*\* Check that the Vercel GitHub integration is installed for your repo. Go to your Vercel project **Settings > Git** and verify the repository is connected. If you just forked the repo via the deploy button, the integration should already be set up. \*\*Warning: Preview deployment stuck on 'Building'\*\* Check the deployment logs in the Vercel dashboard. The most common cause is a missing environment variable that the build depends on. Preview deployments use Preview-scoped variables, so make sure you enabled the Preview scope in lesson 1.2. ## Advanced: Protected Preview Deployments By default, preview deployments are publicly accessible. If your app contains sensitive data or you want to restrict access: 1. Go to **Settings → Deployment Protection** 2. Enable **Vercel Authentication** for preview deployments 3. Only team members with Vercel accounts can access preview URLs This prevents external users from stumbling onto in-progress features. --- title: "Runtime Selection" description: "Configure runtime settings for your SvelteKit server functions on Vercel, including Node.js version, region selection, and experimental Bun support." canonical_url: "https://vercel.com/academy/svelte-on-vercel/runtime-selection" md_url: "https://vercel.com/academy/svelte-on-vercel/runtime-selection.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-08T23:18:27.664Z" content_type: "lesson" course: "svelte-on-vercel" course_title: "Svelte on Vercel" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Runtime Selection # How Vercel Runs Your Functions Your SvelteKit server code runs as Vercel Functions. New projects use Fluid compute by default, which lets multiple requests share an instance and reduces cold starts without requiring you to manage containers. You still control the Node.js version, deployment region, and optional runtimes such as Bun. ## Outcome Add a health-check endpoint, configure the Node.js runtime version, and understand region selection for your SvelteKit functions. ## Fast Track 1. Know the default: SvelteKit on Vercel uses Node.js with Fluid compute 2. Set `runtime: 'nodejs24.x'` in the adapter config to pin a Node.js version 3. Use `regions` to control where your functions run ## Fluid Compute Vercel's Fluid compute is the default for new projects. Instead of the traditional serverless model where each request spins up a new function instance, Fluid keeps your functions warm and reuses them across requests. The practical result: faster response times without any configuration. | | Fluid (default) | Traditional Serverless | | ----------------- | --------------------------------------------------------------- | ------------------------ | | **Cold starts** | Minimal (functions stay warm) | Every new instance | | **Concurrency** | Handles multiple requests per instance | One request per instance | | **Max duration** | 300s by default; higher limits depend on plan and configuration | Lower limits may apply | | **Configuration** | None needed | None needed | The ski-alerts app can use Fluid compute without additional code. The AI chat endpoint benefits from instance reuse because streaming responses keep connections open while the model generates output. ## Hands-on Exercise 1.4 Explore runtime configuration for the ski-alerts app: **Requirements:** 1. Add a health-check endpoint at `/api/health` 2. Pin the Node.js version in the adapter config 3. Verify the runtime settings in the Vercel dashboard after deploying **Implementation hints:** - Set runtime globally in `svelte.config.js` via the adapter options - Per-route config is available by exporting a `config` object from `+server.ts` or `+page.server.ts` - The health-check endpoint is a simple `GET` that returns JSON. You'll use it again in the observability lesson **Global runtime in adapter config:** ```javascript title="svelte.config.js" {4-6} import adapter from '@sveltejs/adapter-vercel'; const config = { kit: { adapter: adapter({ runtime: 'nodejs24.x' }) } }; export default config; ``` ## Try It 1. **Create the health-check endpoint:** ```typescript title="src/routes/api/health/+server.ts" import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; export const GET: RequestHandler = async () => { return json({ status: 'ok', timestamp: new Date().toISOString() }); }; ``` 2. **Pin the Node.js version:** Update `svelte.config.js` to specify the runtime: ```javascript title="svelte.config.js" {5} import adapter from '@sveltejs/adapter-vercel'; const config = { kit: { adapter: adapter({ runtime: 'nodejs24.x' }) } }; export default config; ``` 3. **Deploy and check the dashboard:** Push your changes and look at the **Functions** tab in your Vercel dashboard: ``` /api/chat → Node.js (Serverless) /api/health → Node.js (Serverless) /api/evaluate → Node.js (Serverless) / → Node.js (Serverless) ``` 4. **Hit the health endpoint:** ```bash $ curl https://ski-alerts-xxxxx.vercel.app/api/health {"status":"ok","timestamp":"2026-02-24T12:00:00.000Z"} ``` ## Commit ```bash git add -A git commit -m "feat(runtime): add health endpoint and pin Node.js version" git push ``` ## Done-When - [ ] `/api/health` returns a JSON response with status and timestamp - [ ] `svelte.config.js` specifies a pinned Node.js runtime version - [ ] The Vercel Functions tab shows all routes running on Node.js - [ ] The health endpoint responds on your production URL ## Solution **Health check endpoint:** ```typescript title="src/routes/api/health/+server.ts" import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; export const GET: RequestHandler = async () => { return json({ status: 'ok', timestamp: new Date().toISOString() }); }; ``` **Adapter config with pinned runtime:** ```javascript title="svelte.config.js" import adapter from '@sveltejs/adapter-vercel'; const config = { kit: { adapter: adapter({ runtime: 'nodejs24.x' }) } }; export default config; ``` Every route in the ski-alerts app runs on Node.js. The AI SDK, the Workflow SDK, and the weather service all run in that environment. Pinning `nodejs24.x` keeps the runtime consistent between deployments and provides native support for features such as `Object.groupBy()`. ## Troubleshooting \*\*Warning: Build fails after changing the runtime version\*\* Make sure the runtime string matches Vercel's supported versions. As of early 2026, `nodejs24.x`, `nodejs22.x`, and `nodejs20.x` are supported. Check the Vercel docs for the current list. \*\*Warning: Functions tab shows a different runtime than expected\*\* Per-route `config` exports override the global adapter setting. If a specific route exports its own config, that takes precedence. Check the route file for a `config` export. ## Advanced: Region Configuration By default, your functions deploy to a single region (usually `iad1`, US East). You can change this: ```javascript title="svelte.config.js" {4} adapter({ runtime: 'nodejs24.x', regions: ['sfo1'] // US West, closer to California ski resorts }) ``` For the ski-alerts app, choosing a region near your users or the Open-Meteo API servers can shave off latency on weather data fetches. ## Advanced: Experimental Bun Runtime The SvelteKit Vercel adapter also supports Bun as an experimental runtime. Instead of setting a separate flag, you use it as the runtime value: ```javascript title="svelte.config.js" {3} adapter({ runtime: 'experimental_bun1.x' }) ``` Bun offers faster startup times and a built-in bundler. It's experimental on Vercel, so don't use it for production workloads yet, but it's worth trying if you're curious about the performance difference. The ski-alerts app sticks with Node.js for stability. --- title: "Streaming Chat" description: "Build a streaming chat interface using AI SDK v6 with SvelteKit server endpoints and reactive UI updates." canonical_url: "https://vercel.com/academy/svelte-on-vercel/streaming-chat" md_url: "https://vercel.com/academy/svelte-on-vercel/streaming-chat.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-08T23:18:27.735Z" content_type: "lesson" course: "svelte-on-vercel" course_title: "Svelte on Vercel" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Streaming Chat # Build a Streaming Chat Endpoint Waiting 5 seconds for a complete AI response gives users no sign that the request is working. Streaming sends tokens as the model generates them, so the interface can render progress immediately. This lesson builds that path from the API response to the browser. ## Outcome Build a SvelteKit server endpoint that streams AI responses using the AI SDK and Server-Sent Events. ## Fast Track 1. Set up the AI Gateway provider with your API key 2. Use `streamText()` to get a streaming response from Claude 3. Pipe the stream into an SSE response using `ReadableStream` ## How Streaming Works ``` Browser SvelteKit Claude API │ │ │ │── POST /api/chat ───→ │ │ │ │── streamText() ─────→ │ │ │ │ │ │ ←─ token: "I" ─────── │ │ ←─ SSE: "I" ──────── │ │ │ │ ←─ token: "'ll" ───── │ │ ←─ SSE: "'ll" ─────── │ │ │ │ ←─ token: " help" ─── │ │ ←─ SSE: " help" ───── │ │ │ │ ←─ [done] ──────────── │ │ ←─ SSE: [DONE] ────── │ │ ``` The AI SDK handles the Claude API connection. You handle turning it into Server-Sent Events for the browser. ## Hands-on Exercise 2.1 Replace the placeholder in `src/routes/api/chat/+server.ts` with a streaming implementation: **Requirements:** 1. Import and configure the AI Gateway provider from `ai` 2. Use `streamText()` from the `ai` package with a system prompt about ski resorts 3. Iterate over `result.fullStream` and emit `text-delta` events as SSE 4. Return a `ReadableStream` response with the correct SSE headers **Implementation hints:** - The gateway client needs `AI_GATEWAY_API_KEY` from `$env/static/private` - Use `anthropic/claude-sonnet-4` as the model - The system prompt should list available resorts so the AI knows what to talk about - The `Chat.svelte` component already handles SSE parsing. It expects `data: {"type": "text", "content": "..."}` format - Don't add tools yet; that's the next lesson **SSE format the frontend expects:** ``` data: {"type": "text", "content": "I"} data: {"type": "text", "content": "'ll"} data: {"type": "text", "content": " help"} data: [DONE] ``` ## Try It 1. **Start the dev server and open the app** 2. **Type a message in the chat panel:** ``` What resorts do you know about? ``` 3. **Watch the response stream in:** The AI should respond with information about the 5 available resorts (Mammoth Mountain, Palisades Tahoe, Grand Targhee, Steamboat, Mt. Bachelor). You'll see tokens appear one by one. 4. **Check the Network tab:** - The request to `/api/chat` should show `Content-Type: text/event-stream` - The response streams in chunks rather than arriving all at once \*\*Note: Tools don't work yet\*\* If you ask "alert me when Mammoth gets powder," the AI will respond with text but can't create an alert. You'll add that in the next lesson. ## Commit and Deploy ```bash git add -A git commit -m "feat(chat): implement streaming AI chat endpoint" git push ``` Pushing triggers a new deployment on Vercel so you can test streaming in production. ## Done-When - [ ] Chat endpoint returns streaming SSE responses - [ ] AI responses appear token-by-token in the chat UI - [ ] The AI knows about the 5 ski resorts from the system prompt - [ ] No errors in the browser console or server logs ## Solution ```typescript title="src/routes/api/chat/+server.ts" import { createGateway, streamText } from 'ai'; import { resorts } from '$lib/data/resorts'; import { AI_GATEWAY_API_KEY } from '$env/static/private'; import type { RequestHandler } from './$types'; const gateway = createGateway({ apiKey: AI_GATEWAY_API_KEY }); export const POST: RequestHandler = async ({ request }) => { const { message } = await request.json(); const resortList = resorts.map((r) => `- ${r.name} (id: ${r.id})`).join('\n'); const result = streamText({ model: gateway('anthropic/claude-sonnet-4'), system: `You are a helpful ski conditions assistant. Users want to learn about ski resort conditions. Available resorts: ${resortList} Provide helpful information about these resorts and current conditions.`, messages: [{ role: 'user', content: message }] }); const encoder = new TextEncoder(); const stream = new ReadableStream({ async start(controller) { for await (const part of result.fullStream) { if (part.type === 'text-delta') { controller.enqueue( encoder.encode( `data: ${JSON.stringify({ type: 'text', content: part.text })}\n\n` ) ); } } controller.enqueue(encoder.encode('data: [DONE]\n\n')); controller.close(); } }); return new Response(stream, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' } }); }; ``` The `createGateway()` call sets up the connection to the AI Gateway with your API key. From there, `streamText()` starts the conversation and hands back an async iterable, `result.fullStream`, that emits tokens as they arrive. We wrap each `text-delta` in the SSE format the frontend expects and push it through a `ReadableStream`. The SSE headers tell the browser to keep the connection open and parse events as they flow in. ## Troubleshooting \*\*Warning: Blank chat with no response\*\* Check that `AI_GATEWAY_API_KEY` is set in your `.env` file. If you skipped lesson 1.2 or the key is missing, the gateway client will fail silently and the stream will never start. \*\*Warning: Response arrives all at once instead of streaming\*\* Verify the `Content-Type` header is `text/event-stream`. A missing or wrong header causes the browser to buffer the entire response before rendering. Also check you're not running behind a proxy that buffers SSE connections. ## Advanced: The `Chat` Class from `@ai-sdk/svelte` This lesson builds manual SSE streaming so you understand how it works. In production, AI SDK v6 provides a `Chat` class in `@ai-sdk/svelte` that handles all the client-side stream parsing for you: ```svelte {#each chat.messages as message} {#each message.parts as part} {#if part.type === 'text'}

{part.text}

{/if} {/each} {/each} { e.preventDefault(); chat.sendMessage({ text: input }); input = ''; }}> ``` The server endpoint would use `toUIMessageStreamResponse()` instead of manual SSE: ```typescript return result.toUIMessageStreamResponse(); ``` The manual approach in this lesson gives you full control over the SSE format and event types (like the custom `alert_created` event in the next lesson). Use `Chat` when you don't need custom event handling. ## Advanced: Error Handling in Streams If the API key is missing or the request fails, the stream will error. Add a try/catch inside the `start()` function: ```typescript {3,11-14} const stream = new ReadableStream({ async start(controller) { try { for await (const part of result.fullStream) { if (part.type === 'text-delta') { controller.enqueue( encoder.encode( `data: ${JSON.stringify({ type: 'text', content: part.text })}\n\n` ) ); } } } catch (error) { controller.enqueue( encoder.encode( `data: ${JSON.stringify({ type: 'error', content: 'Stream failed' })}\n\n` ) ); } controller.enqueue(encoder.encode('data: [DONE]\n\n')); controller.close(); } }); ``` --- title: "Tools and Agents" description: "Create tools that extend AI capabilities and build multi-step agents that can chain operations together." canonical_url: "https://vercel.com/academy/svelte-on-vercel/tools-and-agents" md_url: "https://vercel.com/academy/svelte-on-vercel/tools-and-agents.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-08T23:18:27.758Z" content_type: "lesson" course: "svelte-on-vercel" course_title: "Svelte on Vercel" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Tools and Agents # Tools and Multi-Step Agents Streaming text is useful, but it's not enough. When a user says "alert me when Mammoth gets fresh powder," you need the AI to do something: parse that into a structured alert and save it. AI SDK tools give the model the ability to call functions with validated parameters. ## Outcome Add a `create_alert` tool to the chat endpoint so the AI can create structured ski alerts from natural language. ## Fast Track 1. Import `CreateAlertToolInputSchema` and wrap it with `valibotSchema()` for the tool's `inputSchema` 2. Register the tool with `tool()` from the AI SDK 3. Handle `tool-result` events in the SSE stream to notify the frontend ## How Tools Work ``` User: "Alert me when Grand Targhee gets more than 6 inches of snow" │ ▼ Claude sees the create_alert tool is available │ ▼ Claude calls create_alert({ resortId: "grand-targhee", condition: { type: "snowfall", operator: "gt", value: 6, unit: "inches" }}) │ ▼ Tool execute() runs → returns result │ ▼ Claude gets the tool result → writes a confirmation message │ ▼ User sees: "I've created an alert for Grand Targhee. You'll be notified when snowfall exceeds 6 inches." ``` The `stopWhen: stepCountIs(3)` parameter controls how many tool-call/result rounds the model can do before finishing. With 3 steps, the model can call a tool, get the result, and then respond, or chain multiple tool calls. ## Hands-on Exercise 2.2 Extend the streaming chat endpoint from lesson 2.1 with tool support: **Requirements:** 1. Import `CreateAlertToolInputSchema` from `$lib/schemas/alert` and wrap it with `valibotSchema()` from `@ai-sdk/valibot` 2. Register it as a `create_alert` tool using the AI SDK's `tool()` function with `inputSchema` 3. Set `stopWhen: stepCountIs(3)` to allow the model to call the tool and respond 4. Handle `tool-result` events in the stream and emit an `alert_created` SSE event when a tool succeeds 5. Update the system prompt to instruct the model on how to parse natural language into alerts **Implementation hints:** - The Valibot schema `CreateAlertToolInputSchema` already defines the three alert types (snowfall, temperature, conditions). Wrap it with `valibotSchema()` for the AI SDK - The tool's `execute` function should validate the resort exists and return structured data - The `Chat.svelte` component already handles `alert_created` events. It calls `createAlert()` to save to localStorage - The system prompt should explain how to map phrases like "fresh powder" to condition types ## Try It 1. **Test natural language alert creation:** ``` Alert me when Mammoth gets fresh powder ``` The AI should: - Call the `create_alert` tool with `{ resortId: "mammoth", condition: { type: "conditions", match: "powder" } }` - Return a confirmation message explaining the alert 2. **Test a numeric condition:** ``` Notify me when Grand Targhee gets more than 6 inches of snow ``` Expected tool call: `{ resortId: "grand-targhee", condition: { type: "snowfall", operator: "gt", value: 6, unit: "inches" } }` 3. **Test a temperature condition:** ``` Let me know when Steamboat drops below 10°F ``` Expected tool call: `{ resortId: "steamboat", condition: { type: "temperature", operator: "lt", value: 10, unit: "fahrenheit" } }` 4. **Check the Alerts page:** Navigate to `/alerts`. Your created alerts should appear there, saved to localStorage. ## Commit ```bash git add -A git commit -m "feat(chat): add create_alert tool with multi-step agent" git push ``` ## Done-When - [ ] AI can create alerts from natural language requests - [ ] Three condition types work: snowfall, temperature, conditions - [ ] Created alerts appear on the `/alerts` page - [ ] The AI responds with a confirmation after creating an alert - [ ] Invalid resort names are handled gracefully ## Solution ```typescript title="src/routes/api/chat/+server.ts" import { createGateway, streamText, tool, stepCountIs } from 'ai'; import { valibotSchema } from '@ai-sdk/valibot'; import { resorts } from '$lib/data/resorts'; import { CreateAlertToolInputSchema } from '$lib/schemas/alert'; import { AI_GATEWAY_API_KEY } from '$env/static/private'; import type { RequestHandler } from './$types'; const gateway = createGateway({ apiKey: AI_GATEWAY_API_KEY }); export const POST: RequestHandler = async ({ request }) => { const { message } = await request.json(); const resortList = resorts .map((r) => `- ${r.name} (id: ${r.id})`) .join('\n'); const result = streamText({ model: gateway('anthropic/claude-sonnet-4'), system: `You are a helpful ski conditions assistant. Users want to create alerts for ski resort conditions. Available resorts: ${resortList} When a user asks you to create an alert, use the create_alert tool with the appropriate parameters. Parse natural language like "notify me when Mammoth gets fresh powder" into structured alert conditions. For "fresh powder" or "new snow", use the conditions type with match: "powder". For specific snow amounts like "more than 6 inches", use snowfall type with the appropriate operator. For temperature conditions, use the temperature type. Always confirm the alert was created and explain what conditions will trigger it.`, messages: [{ role: 'user', content: message }], tools: { create_alert: tool({ description: 'Create a new alert for ski resort conditions', inputSchema: valibotSchema(CreateAlertToolInputSchema), execute: async ({ resortId, condition }) => { const resort = resorts.find((r) => r.id === resortId); if (!resort) { return { success: false, error: `Resort "${resortId}" not found` }; } return { success: true, alert: { resortId, resortName: resort.name, condition, message: `Alert created for ${resort.name}` } }; } }) }, stopWhen: stepCountIs(3) }); const encoder = new TextEncoder(); const stream = new ReadableStream({ async start(controller) { for await (const part of result.fullStream) { if (part.type === 'text-delta') { controller.enqueue( encoder.encode( `data: ${JSON.stringify({ type: 'text', content: part.text })}\n\n` ) ); } else if (part.type === 'tool-result') { const toolResult = part.output as { success: boolean; alert?: unknown; }; if (toolResult.success && toolResult.alert) { controller.enqueue( encoder.encode( `data: ${JSON.stringify({ type: 'alert_created', alert: toolResult.alert })}\n\n` ) ); } } } controller.enqueue(encoder.encode('data: [DONE]\n\n')); controller.close(); } }); return new Response(stream, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' } }); }; ``` What changed from lesson 2.1: 1. **Added imports:** `tool`, `stepCountIs` alongside `createGateway` and `streamText` from `ai`, `valibotSchema` from `@ai-sdk/valibot`, `CreateAlertToolInputSchema` from the shared schemas 2. **Used `valibotSchema()`** to wrap the existing Valibot schema for the AI SDK tool's `inputSchema` 3. **Registered the tool** in `streamText()` with `tools: { create_alert: tool({ ... }) }` 4. **Added `stopWhen: stepCountIs(3)`** so the model can call the tool, get the result, and write a response 5. **Handle `tool-result`** events in the stream to emit `alert_created` SSE events 6. **Updated system prompt** with instructions for parsing natural language into alert conditions ## Troubleshooting \*\*Warning: AI responds with text instead of calling the tool\*\* Check your system prompt. The model needs explicit instructions about when to use `create_alert` vs. when to just answer a question. If the prompt doesn't mention the tool or describe when to use it, the model will default to text responses. \*\*Warning: Valibot validation error in server logs\*\* The model generated a condition that doesn't match the schema. The `stopWhen: stepCountIs(3)` gives it multiple rounds to self-correct, but if it keeps failing, make the tool description more specific about the expected condition shapes. ## Advanced: Multiple Tools You can register multiple tools. For example, adding a `check_conditions` tool that fetches live weather: ```typescript {3-14} tools: { create_alert: tool({ /* ... */ }), check_conditions: tool({ description: 'Check current conditions at a ski resort', inputSchema: valibotSchema(v.object({ resortId: v.string() })), execute: async ({ resortId }) => { const resort = resorts.find((r) => r.id === resortId); if (!resort) return { error: 'Resort not found' }; const weather = await fetchWeather(resort); return { resort: resort.name, ...weather }; } }) } ``` With `stopWhen: stepCountIs(3)`, the model could check conditions first, then create an alert based on what it finds. --- title: "Structured Output" description: "Extract structured, type-safe data from AI responses using Valibot schemas for reliable data extraction." canonical_url: "https://vercel.com/academy/svelte-on-vercel/svelte-structured-output" md_url: "https://vercel.com/academy/svelte-on-vercel/svelte-structured-output.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-08T23:18:27.777Z" content_type: "lesson" course: "svelte-on-vercel" course_title: "Svelte on Vercel" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Structured Output # Structured Output with Valibot Schemas Tools let the AI *do* things. But sometimes you don't need the AI to do anything. You need it to *understand* something. Take a messy human sentence like "fresh pow at Palisades" and turn it into clean, typed JSON your app can trust. The AI SDK's `generateText()` with `Output.object()` does exactly this, reusing the same Valibot schemas you already have. ## Outcome Build a `/api/parse-alert` endpoint that uses `generateText()` with `Output.object()` to extract structured alert data from natural language. ## Fast Track 1. Wrap a Valibot schema with `valibotSchema()` and pass it to `Output.object()` 2. Use `generateText()` with the `output` option to get typed, validated responses 3. Validate the result with Valibot's `parse()` for runtime safety ## Tools vs Structured Output Both give you structured data from the AI. The difference: | | Tools | Structured Output | | ------------------ | --------------------------------- | ------------------------------ | | **Mechanism** | Model calls a function | Model returns a JSON object | | **Use when** | You need to execute side effects | You need pure data extraction | | **Streaming** | Text streams alongside tool calls | Object streams as partial JSON | | **Schema library** | Valibot with `valibotSchema()` | Valibot with `valibotSchema()` | The chat endpoint uses tools because creating an alert is a side effect. This new endpoint uses structured output because parsing text into data is pure extraction. ## The Valibot Schema The ski-alerts app already defines alert schemas in `src/lib/schemas/alert.ts`: ```typescript title="src/lib/schemas/alert.ts" {1-18} import * as v from 'valibot'; export const AlertConditionSchema = v.variant('type', [ v.object({ type: v.literal('snowfall'), operator: v.picklist(['gt', 'gte', 'lt', 'lte']), value: v.number(), unit: v.literal('inches') }), v.object({ type: v.literal('temperature'), operator: v.picklist(['gt', 'gte', 'lt', 'lte']), value: v.number(), unit: v.picklist(['fahrenheit', 'celsius']) }), v.object({ type: v.literal('conditions'), match: v.picklist(['powder', 'clear', 'snowing', 'windy']) }) ]); ``` You'll use this same schema to constrain the AI's output. ## Hands-on Exercise 2.3 Create an endpoint that parses natural-language alert descriptions into structured data: **Requirements:** 1. Complete the endpoint at `src/routes/api/parse-alert/+server.ts` 2. Use `generateText()` with `Output.object()` and `valibotSchema(CreateAlertToolInputSchema)` for structured output 3. Accept a `query` string in the POST body (e.g., "powder at Mammoth") 4. Return the parsed alert condition as validated JSON 5. Validate the AI's output with Valibot's `parse()` before returning **Implementation hints:** - Import `Output` from `ai` and `valibotSchema` from `@ai-sdk/valibot` - Reuse `CreateAlertToolInputSchema` from `$lib/schemas/alert`, the same schema you used for the tool - After getting the AI result, validate it with `v.parse(AlertConditionSchema, output.condition)` for double safety - Include the resort list in the prompt so the AI can resolve resort names to IDs ## Try It 1. **Test with curl:** ```bash $ curl -X POST http://localhost:5173/api/parse-alert \ -H "Content-Type: application/json" \ -d '{"query": "more than 6 inches of snow at Grand Targhee"}' ``` Expected response: ```json { "resortId": "grand-targhee", "resortName": "Grand Targhee", "condition": { "type": "snowfall", "operator": "gt", "value": 6, "unit": "inches" }, "originalQuery": "more than 6 inches of snow at Grand Targhee" } ``` 2. **Test ambiguous input:** ```bash $ curl -X POST http://localhost:5173/api/parse-alert \ -H "Content-Type: application/json" \ -d '{"query": "fresh pow at Palisades"}' ``` Expected: ```json { "resortId": "palisades", "resortName": "Palisades Tahoe", "condition": { "type": "conditions", "match": "powder" }, "originalQuery": "fresh pow at Palisades" } ``` 3. **Test invalid input:** ```bash $ curl -X POST http://localhost:5173/api/parse-alert \ -H "Content-Type: application/json" \ -d '{"query": "hello world"}' ``` The AI should still attempt to parse it. If it can't extract a meaningful alert, the validation step catches it. ## Commit ```bash git add -A git commit -m "feat(parse): add structured output endpoint with Valibot validation" git push ``` ## Done-When - [ ] `/api/parse-alert` accepts a natural language query and returns structured JSON - [ ] Output matches the `AlertCondition` schema shape - [ ] Resort names are resolved to IDs correctly - [ ] Invalid inputs return a clear error response ## Solution ```typescript title="src/routes/api/parse-alert/+server.ts" import { json } from '@sveltejs/kit'; import { createGateway, generateText, Output } from 'ai'; import { valibotSchema } from '@ai-sdk/valibot'; import * as v from 'valibot'; import { resorts } from '$lib/data/resorts'; import { CreateAlertToolInputSchema, AlertConditionSchema } from '$lib/schemas/alert'; import { AI_GATEWAY_API_KEY } from '$env/static/private'; import type { RequestHandler } from './$types'; const gateway = createGateway({ apiKey: AI_GATEWAY_API_KEY }); export const POST: RequestHandler = async ({ request }) => { const { query } = await request.json(); if (!query || typeof query !== 'string') { return json({ error: 'query string required' }, { status: 400 }); } const resortList = resorts .map((r) => `- ${r.name} (id: ${r.id})`) .join('\n'); const { output } = await generateText({ model: gateway('anthropic/claude-sonnet-4'), output: Output.object({ schema: valibotSchema(CreateAlertToolInputSchema) }), prompt: `Parse this natural language alert request into structured data. Available resorts: ${resortList} Map common phrases: - "fresh powder", "pow", "new snow" → conditions type with match: "powder" - "snowing", "snowfall" with no amount → conditions type with match: "snowing" - Specific amounts like "6 inches" → snowfall type with operator - Temperature references → temperature type with operator User request: "${query}"` }); if (!output) { return json( { error: 'AI returned no structured output' }, { status: 422 } ); } // Validate the condition with Valibot for runtime type safety try { v.parse(AlertConditionSchema, output.condition); } catch { return json( { error: 'AI returned invalid condition structure' }, { status: 422 } ); } const resort = resorts.find((r) => r.id === output.resortId); return json({ resortId: output.resortId, resortName: resort?.name ?? output.resortId, condition: output.condition, originalQuery: query }); }; ``` `generateText()` with `Output.object()` constrains the model to output JSON matching the schema: no free-text, just data. We reuse the same `CreateAlertToolInputSchema` from the tool definition, so there's no schema duplication. The `v.parse()` call after the AI response adds a second validation layer for runtime safety. No streaming needed here. Structured output is a single request/response. ## Troubleshooting \*\*Warning: AI returns null for the output\*\* Your prompt may not be specific enough. Make sure you include the resort list and phrase mappings so the model has enough context to generate valid JSON. If the model can't figure out what the user wants, `Output.object()` returns `null`. \*\*Warning: Valibot parse() throws after the AI response\*\* The AI generated a condition that doesn't match the schema. Log the raw output with `console.log(output)` before the validation step to see what the model actually returned. Common issue: the model picks an operator or match value that isn't in the picklist. ## Advanced: Streaming Structured Output For large objects, you can stream partial results with `streamText()` and `Output.object()`. The `partialOutputStream` async iterable emits progressively complete objects as the AI generates: ```typescript import { streamText, Output } from 'ai'; const { partialOutputStream } = streamText({ model: gateway('anthropic/claude-sonnet-4'), output: Output.object({ schema: valibotSchema(CreateAlertToolInputSchema) }), prompt: `Parse: "${query}"` }); for await (const partialObject of partialOutputStream) { console.log(partialObject); // Progressively complete object } ``` Each iteration yields a more complete version of the final object. This is useful when the schema is large and you want to show progressive results in the UI. --- title: "Fallbacks and Tracking" description: "Configure model fallbacks for reliability and track token usage for cost management in production AI applications." canonical_url: "https://vercel.com/academy/svelte-on-vercel/fallbacks-and-tracking" md_url: "https://vercel.com/academy/svelte-on-vercel/fallbacks-and-tracking.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-08T23:18:27.797Z" content_type: "lesson" course: "svelte-on-vercel" course_title: "Svelte on Vercel" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Fallbacks and Tracking # Model Fallbacks and Usage Tracking A single model going down shouldn't take your app with it. And if you don't track token usage, you'll find out about costs from your invoice instead of your dashboard. ## Outcome Create a centralized AI provider configuration with automatic model fallbacks and token usage logging. ## Fast Track 1. Configure fallback models in the AI Gateway dashboard (the preferred approach) 2. Create `src/lib/ai/provider.ts` with a shared AI Gateway client 3. Add a `wrapLanguageModel` middleware that logs token usage ## Gateway-Level Fallbacks (Preferred) Configure the fallback in AI Gateway so every endpoint uses the same routing policy. In the Vercel dashboard: 1. Go to your project **Settings** → **AI Gateway** 2. Select your primary model (`anthropic/claude-sonnet-4`) 3. Add a fallback model (`anthropic/claude-haiku-4.5`) 4. Set conditions: timeout threshold, error codes that trigger fallback When the primary model is unavailable or slow, the gateway routes the request to the fallback without endpoint-specific retry logic or duplicate model configuration. Use gateway-level fallbacks when the same policy applies across the app. The Advanced section covers code-level fallbacks for endpoint-specific behavior or model-specific prompt changes. ## Why Centralize the Provider? Right now, each endpoint creates its own gateway client: ```typescript // In api/chat/+server.ts const gateway = createGateway({ apiKey: AI_GATEWAY_API_KEY }); // In api/parse-alert/+server.ts (same thing, duplicated) const gateway = createGateway({ apiKey: AI_GATEWAY_API_KEY }); ``` A centralized provider means one place to: - Configure the API key - Add usage tracking middleware - Define fallback models - Adjust settings across all endpoints ## Hands-on Exercise 2.4 Create a centralized AI provider with usage tracking and model fallbacks: **Requirements:** 1. Configure fallback models in the AI Gateway dashboard 2. Create `src/lib/ai/provider.ts` 3. Use `wrapLanguageModel` to add middleware that logs input/output token counts 4. Export a `getModel()` function that returns a wrapped model instance 5. Update the chat and parse-alert endpoints to use the shared provider **Implementation hints:** - Set up gateway-level fallbacks first (dashboard config, no code needed) - `wrapLanguageModel` from the `ai` package wraps any model with middleware hooks - The middleware object needs `specificationVersion: 'v3'` - `wrapGenerate` intercepts `generateText()` calls, `wrapStream` intercepts `streamText()` calls. You need both to cover all endpoints - Token usage is available as `result.usage.inputTokens.total` and `result.usage.outputTokens.total` ## Try It 1. **Send a chat message and check server logs:** ``` What's the weather like at Mammoth? ``` Server logs should show: ``` [AI Usage] Model: anthropic/claude-sonnet-4 [AI Usage] Input tokens: 245 [AI Usage] Output tokens: 89 [AI Usage] Total tokens: 334 ``` 2. **Test the parse-alert endpoint (also uses the shared provider):** ```bash $ curl -X POST http://localhost:5173/api/parse-alert \ -H "Content-Type: application/json" \ -d '{"query": "powder at Grand Targhee"}' ``` Server logs should show usage for this request too. 3. **Verify fallback behavior:** Temporarily change the primary model to an invalid name and verify the fallback model handles the request. ## Commit ```bash git add -A git commit -m "feat(ai): centralize provider with usage tracking and fallbacks" git push ``` ## Done-When - [ ] Fallback model is configured in the AI Gateway dashboard - [ ] `src/lib/ai/provider.ts` exports a `getModel()` function - [ ] Token usage is logged for every AI request - [ ] Chat and parse-alert endpoints use the shared provider instead of their own clients ## Solution ```typescript title="src/lib/ai/provider.ts" import { createGateway, wrapLanguageModel } from 'ai'; import { AI_GATEWAY_API_KEY } from '$env/static/private'; const gateway = createGateway({ apiKey: AI_GATEWAY_API_KEY }); const PRIMARY_MODEL = 'anthropic/claude-sonnet-4'; const FALLBACK_MODEL = 'anthropic/claude-haiku-4.5'; function logUsage(usage: { inputTokens: { total?: number }; outputTokens: { total?: number } }) { const input = usage.inputTokens.total ?? 0; const output = usage.outputTokens.total ?? 0; console.log(`[AI Usage] Input tokens: ${input}`); console.log(`[AI Usage] Output tokens: ${output}`); console.log(`[AI Usage] Total tokens: ${input + output}`); } function withUsageTracking(model: ReturnType) { return wrapLanguageModel({ model, middleware: { specificationVersion: 'v3', wrapGenerate: async ({ doGenerate }) => { const result = await doGenerate(); if (result.usage) logUsage(result.usage); return result; }, wrapStream: async ({ doStream }) => { const { stream, ...rest } = await doStream(); let usage: typeof rest.rawResponse | undefined; return { stream: stream.pipeThrough( new TransformStream({ transform(chunk, controller) { if (chunk.type === 'usage') usage = chunk.value; controller.enqueue(chunk); }, flush() { if (usage) logUsage(usage); } }) ), ...rest }; } } }); } export function getModel() { return withUsageTracking(gateway(PRIMARY_MODEL)); } export function getFallbackModel() { return withUsageTracking(gateway(FALLBACK_MODEL)); } export { PRIMARY_MODEL, FALLBACK_MODEL }; ``` **Updated chat endpoint using the shared provider:** ```typescript title="src/routes/api/chat/+server.ts" {1,6} import { getModel } from '$lib/ai/provider'; import { streamText, tool, stepCountIs } from 'ai'; import { valibotSchema } from '@ai-sdk/valibot'; import { resorts } from '$lib/data/resorts'; import { CreateAlertToolInputSchema } from '$lib/schemas/alert'; import type { RequestHandler } from './$types'; // Remove the local anthropic client. Use getModel() instead export const POST: RequestHandler = async ({ request }) => { const { message } = await request.json(); const resortList = resorts .map((r) => `- ${r.name} (id: ${r.id})`) .join('\n'); const result = streamText({ model: getModel(), // Uses the centralized, tracked model system: `You are a helpful ski conditions assistant...`, messages: [{ role: 'user', content: message }], tools: { create_alert: tool({ /* ... same as before */ }) }, stopWhen: stepCountIs(3) }); // ... rest of the SSE stream logic unchanged }; ``` **Updated parse-alert endpoint:** The same change applies. Replace the local `createGateway` and `gateway(...)` call with `getModel()`: ```typescript title="src/routes/api/parse-alert/+server.ts" {1,7} import { generateText, Output } from 'ai'; import { valibotSchema } from '@ai-sdk/valibot'; import * as v from 'valibot'; import { resorts } from '$lib/data/resorts'; import { CreateAlertToolInputSchema, AlertConditionSchema } from '$lib/schemas/alert'; import type { RequestHandler } from './$types'; import { getModel } from '$lib/ai/provider'; // Remove the local gateway client. Use getModel() in the generateText call: // model: getModel(), ``` The gateway handles fallbacks at the infrastructure level (configured in the dashboard earlier). The `getFallbackModel()` export is available for cases where you need explicit code-level control, covered in the Advanced section below. `wrapLanguageModel` intercepts the model's lifecycle. `wrapGenerate` handles `generateText()` calls (like the parse-alert endpoint), while `wrapStream` handles `streamText()` calls (like the chat endpoint). Both hooks need to be present to track usage across all endpoints. The middleware needs `specificationVersion: 'v3'` in the v6 SDK. Usage data lives on `result.usage` with `inputTokens.total` and `outputTokens.total`. Since all endpoints now use `getModel()`, tracking and config changes apply everywhere from one file. ## Troubleshooting \*\*Warning: Token counts show 0 for both input and output\*\* Your middleware may not be wired up correctly. Verify that `withUsageTracking` is being called and that `getModel()` returns the wrapped model, not the raw gateway model. \*\*Warning: result.usage is undefined\*\* Check that `specificationVersion: 'v3'` is set in the middleware object. Without it, the v6 SDK won't pass usage data to your hooks. ## Advanced: Code-Level Fallbacks If you need fallback behavior that's more nuanced than the gateway dashboard allows, like adjusting the prompt for a different model or falling back only for specific endpoints, handle it in code. The `getFallbackModel()` export from the provider gives you a cheaper, faster model: ```typescript import { getModel, getFallbackModel } from '$lib/ai/provider'; // In your stream's start() function: async start(controller) { try { for await (const part of result.fullStream) { // ... handle parts } } catch (error) { console.warn('[AI Fallback] Primary stream failed, retrying with fallback'); const fallbackResult = streamText({ ...options, model: getFallbackModel() }); for await (const part of fallbackResult.fullStream) { // ... handle parts } } } ``` For most apps, the gateway-level approach is simpler and sufficient. Use code-level fallbacks when you need the extra control. ## Advanced: Cost Estimation Add per-request cost estimates to your logs: ```typescript // Approximate pricing (check anthropic.com/pricing for current rates) const PRICING = { 'anthropic/claude-sonnet-4': { input: 3.0, output: 15.0 }, // per million tokens 'anthropic/claude-haiku-4.5': { input: 0.25, output: 1.25 } }; function estimateCost( modelId: string, inputTokens: number, outputTokens: number ): string { const prices = PRICING[modelId as keyof typeof PRICING]; if (!prices) return 'unknown'; const cost = (inputTokens / 1_000_000) * prices.input + (outputTokens / 1_000_000) * prices.output; return `$${cost.toFixed(6)}`; } ``` In production, you'd send these metrics to a monitoring service rather than just logging them. --- title: "Your First Workflow" description: "Install the Workflow SDK, create a durable workflow with steps, and trigger it from a SvelteKit route handler." canonical_url: "https://vercel.com/academy/svelte-on-vercel/durable-tasks" md_url: "https://vercel.com/academy/svelte-on-vercel/durable-tasks.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-08T23:18:27.827Z" content_type: "lesson" course: "svelte-on-vercel" course_title: "Svelte on Vercel" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Your First Workflow # Your First Workflow Your serverless function has the lifespan of a mayfly. Request comes in, response goes out, function dies. `waitUntil()` from `@vercel/functions` buys you some extra seconds, like a mayfly that found a really good energy drink. But what happens when the weather API is down for 30 seconds? Or when Vercel redeploys your app mid-evaluation? The mayfly is dead, and so is your work. The [Workflow SDK](https://workflow-sdk.dev) handles work that outlives a request. A workflow can pause, retry, and resume across server restarts, and each step has its own retry lifecycle. If a function stops mid-step, the platform resumes the run from its recorded state. ## Outcome Install the Workflow SDK, create a durable workflow that evaluates ski alerts against live weather data, and trigger it from a route handler. ## Fast Track 1. Install `workflow` and add `workflowPlugin()` to your Vite config 2. Create a workflow file with `"use workflow"` and `"use step"` directives 3. Trigger it from a route handler with `start()` ## Workflows vs Steps Two directives, two roles: ``` "use workflow" "use step" ┌─────────────────────┐ ┌─────────────────────┐ │ Orchestrator │ │ Worker │ │ Sandboxed │ │ Full Node.js │ │ Deterministic │ │ Side effects OK │ │ Controls flow │ │ Auto-retries (3x) │ │ Calls steps │ │ Does the real work │ └─────────────────────┘ └─────────────────────┘ ``` The workflow function controls the sequence: loops, branches, and parallel work. Step functions handle side effects such as fetching data, calling APIs, and reading files. If a step fails, the Workflow SDK retries it automatically (3 times by default) without re-running completed steps. Keep `fetchWeather` out of the workflow function because workflow functions are sandboxed for determinism and cannot access the network or file system. Use them to coordinate work, and put side effects in step functions. ## Hands-on Exercise 3.1 Set up the Workflow SDK and create your first workflow: **Requirements:** 1. Install the `workflow` package 2. Add `workflowPlugin()` to `vite.config.ts` 3. Create `workflows/evaluate-alerts.ts` with a workflow function and a step function 4. Complete the route handler at `src/routes/api/workflow/+server.ts` to start the workflow 5. The step should group alerts by resort, fetch weather for each, and evaluate conditions **Implementation hints:** - The workflow file goes at the project root in a `workflows/` directory (Workflow SDK convention) - Import `workflowPlugin` from `workflow/sveltekit` and add it to your Vite plugins array - The workflow function uses `"use workflow"` as the first line. The step function uses `"use step"` - Inside a step, use dynamic imports for `$lib` modules: `const { getResort } = await import('$lib/data/resorts')` - Use `start()` from `workflow/api` in the route handler. It returns a run object immediately without waiting for the workflow to complete - `Object.groupBy()` handles alert grouping (Node 24 supports it natively) \*\*Note: Fluid compute should be enabled\*\* The Workflow SDK is designed to use Vercel's Fluid compute. Without it, each workflow resumption can trigger a cold start. New Vercel projects enable Fluid compute by default, but verify the setting before you deploy. ## Try It 1. **Install and configure:** ```bash npm install workflow ``` Restart your dev server after updating `vite.config.ts`. 2. **Trigger the workflow:** ```bash $ curl -X POST http://localhost:5173/api/workflow \ -H "Content-Type: application/json" \ -d '{"alerts": [{"id": "test-1", "resortId": "mammoth", "condition": {"type": "conditions", "match": "powder"}, "originalQuery": "test", "createdAt": "2025-01-01", "triggered": false}]}' ``` Expected response: ```json { "runId": "wf_abc123...", "status": "started" } ``` The workflow runs in the background. The route handler returns immediately. 3. **Check server logs:** ``` [Workflow] Complete { evaluated: 1, triggered: 0 } ``` 4. **Inspect in the Workflow dashboard:** ```bash npx workflow web ``` Open the dashboard and you'll see your workflow run with its step, inputs, outputs, and timing. ## Commit ```bash git add -A git commit -m "feat(workflow): add Workflow SDK alert evaluation" git push ``` ## Done-When - [ ] `workflow` package is installed and `workflowPlugin()` is in `vite.config.ts` - [ ] `workflows/evaluate-alerts.ts` exists with `"use workflow"` and `"use step"` directives - [ ] `/api/workflow` route handler starts the workflow and returns a run ID - [ ] Workflow evaluates alerts against live weather data - [ ] `npx workflow web` shows the completed workflow run ## Solution **1. Vite config:** ```typescript title="vite.config.ts" {4,8} import tailwindcss from '@tailwindcss/vite'; import { sveltekit } from '@sveltejs/kit/vite'; import { defineConfig } from 'vite'; import { workflowPlugin } from 'workflow/sveltekit'; export default defineConfig({ plugins: [ tailwindcss(), workflowPlugin(), sveltekit() ] }); ``` **2. Workflow file:** ```typescript title="workflows/evaluate-alerts.ts" import type { Alert } from '$lib/schemas/alert'; interface EvaluateInput { alerts: Alert[]; } export default async function evaluateAlerts({ alerts }: EvaluateInput) { "use workflow"; const results = await evaluateAllAlerts(alerts); console.log('[Workflow] Complete', { evaluated: results.length, triggered: results.filter((r) => r.triggered).length }); return results; } async function evaluateAllAlerts(alerts: Alert[]) { "use step"; const { getResort } = await import('$lib/data/resorts'); const { fetchWeather } = await import('$lib/services/weather'); const { evaluateCondition } = await import('$lib/services/alerts'); const alertsByResort = Object.groupBy(alerts, (a) => a.resortId); const results = []; for (const [resortId, resortAlerts] of Object.entries(alertsByResort)) { const resort = getResort(resortId); if (!resort) continue; const weather = await fetchWeather(resort); for (const alert of resortAlerts!) { const triggered = evaluateCondition(alert.condition, weather); results.push({ alertId: alert.id, resortId, triggered }); } } return results; } ``` **3. Route handler:** ```typescript title="src/routes/api/workflow/+server.ts" import { json } from '@sveltejs/kit'; import { start } from 'workflow/api'; import evaluateAlerts from '../../../../workflows/evaluate-alerts'; import type { RequestHandler } from './$types'; export const POST: RequestHandler = async ({ request }) => { const { alerts } = await request.json(); if (!alerts || !Array.isArray(alerts)) { return json({ error: 'alerts array required' }, { status: 400 }); } const run = await start(evaluateAlerts, [{ alerts }]); return json({ runId: run.runId, status: 'started' }); }; ``` The `"use workflow"` directive marks `evaluateAlerts` as the orchestrator. It calls `evaluateAllAlerts`, which is a step function (marked with `"use step"`) that does the actual work: fetching weather data and evaluating conditions. If the step fails, the platform retries it up to 3 times automatically. `start()` enqueues the workflow and returns immediately. It doesn't block the route handler. The workflow runs in the background with its own lifecycle: it can pause, retry failed steps, and survive function restarts. ## Troubleshooting \*\*Warning: Module not found errors for $lib imports\*\* Step functions run in their own context. If `$lib` path aliases don't resolve, use the full relative path instead: `await import('../../src/lib/data/resorts')`. The `workflowPlugin()` should handle SvelteKit aliases, but check that the plugin is loaded before `sveltekit()` in your Vite config. \*\*Warning: Workflow starts but step never completes\*\* Check the dev server logs for errors inside the step function. Step failures are retried silently by default. Run `npx workflow web` to see the step status and any error messages. If the step failed 3 times and exhausted retries, you'll see it marked as failed in the dashboard. ## Advanced: How Durable Execution Works When the Workflow SDK runs your code, it records each step's input and output in an event log. If the function restarts mid-execution, the SDK replays that log to reconstruct state without re-running completed steps. Workflow functions must therefore be deterministic: every replay must make the same decisions. ``` First run: evaluateAllAlerts(alerts) → runs step → records result ✓ Function restarts mid-workflow: evaluateAllAlerts(alerts) → replays recorded result (skip!) ✓ ...continues with next steps ``` This is also why `Math.random()` and `Date.now()` are fixed during workflow replay. The SDK intercepts them to preserve determinism. --- title: "Parallel Steps" description: "Refactor the workflow to process resorts as independent parallel steps and use sleep to schedule delayed re-evaluation." canonical_url: "https://vercel.com/academy/svelte-on-vercel/multi-step-workflows" md_url: "https://vercel.com/academy/svelte-on-vercel/multi-step-workflows.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-08T23:18:27.843Z" content_type: "lesson" course: "svelte-on-vercel" course_title: "Svelte on Vercel" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Parallel Steps # Parallel Steps and Sleep The workflow from lesson 3.1 checks resorts one at a time. That makes total latency the sum of every weather request and forces successful resorts to run again when one request fails. If each weather request takes 500ms, checking 5 resorts serially takes about 2.5 seconds. Splitting the work into one parallel step per resort cuts that wait and isolates retries. After evaluation finishes, `sleep()` schedules a re-check without a cron job or external scheduler. ## Outcome Refactor the workflow to run one step per resort in parallel and use `sleep()` to schedule automatic re-evaluation. ## Fast Track 1. Extract a `evaluateResort` step function that handles a single resort 2. Use `Promise.all` in the workflow to run all resort steps concurrently 3. Add `sleep()` to pause the workflow and re-check later if no alerts triggered ## One Step per Resort In 3.1, one big step did all the work. The problem: if the weather API fails for Mammoth, the entire step retries, including the successful fetches for the other 4 resorts. ``` Lesson 3.1 (one step): [evaluateAllAlerts] → mammoth → palisades → ... → mt-bachelor If palisades fails → entire step retries from mammoth Lesson 3.2 (one step per resort): [evaluateResort: mammoth] ┐ [evaluateResort: palisades] ├→ parallel, independent retries [evaluateResort: steamboat] │ [evaluateResort: targhee] │ [evaluateResort: bachelor] ┘ If palisades fails → only palisades retries ``` Each step is independently retryable. If Palisades times out, only Palisades retries. The other 4 results are already recorded. ## Hands-on Exercise 3.2 Refactor the workflow to use parallel steps and sleep: **Requirements:** 1. Extract `evaluateResort(resortId, alerts)` as its own `"use step"` function 2. In the workflow function, group alerts by resort and dispatch parallel steps with `Promise.all` 3. After evaluation, if no alerts triggered and we haven't rechecked 3 times, `sleep('30m')` and re-evaluate 4. Return the final results with the number of rounds completed **Implementation hints:** - `Promise.all` in a workflow function dispatches steps concurrently. The platform runs them in parallel - `sleep('30m')` suspends the workflow for 30 minutes without keeping a function active, then resumes it - For local testing, use `sleep('10s')` instead of `sleep('30m')` so you don't wait half an hour - The workflow function can call itself recursively for re-checks by returning the result of another `evaluateAlerts` call with an incremented counter - Data between workflow and step functions is serialized (passed by value). Return modified data from steps rather than mutating shared state ## Try It 1. **Trigger the workflow with alerts for multiple resorts:** ```bash $ curl -X POST http://localhost:5173/api/workflow \ -H "Content-Type: application/json" \ -d '{"alerts": [{"id": "a1", "resortId": "mammoth", "condition": {"type": "conditions", "match": "powder"}, "originalQuery": "test", "createdAt": "2025-01-01", "triggered": false}, {"id": "a2", "resortId": "grand-targhee", "condition": {"type": "temperature", "operator": "lt", "value": 20, "unit": "fahrenheit"}, "originalQuery": "test", "createdAt": "2025-01-01", "triggered": false}, {"id": "a3", "resortId": "steamboat", "condition": {"type": "snowfall", "operator": "gt", "value": 6, "unit": "inches"}, "originalQuery": "test", "createdAt": "2025-01-01", "triggered": false}]}' ``` 2. **Open the Workflow dashboard:** ```bash npx workflow web ``` You should see three parallel `evaluateResort` steps, one for each resort. They start at roughly the same time instead of sequentially. 3. **Check server logs:** ``` [Workflow] Round complete { round: 1, evaluated: 3, triggered: 0 } ``` 4. **Observe the sleep state:** If no alerts triggered, the workflow enters a sleep state. In `npx workflow web`, you'll see the workflow paused, waiting to resume. For local testing with `sleep('10s')`, it resumes after 10 seconds and runs round 2. ## Commit ```bash git add -A git commit -m "feat(workflow): parallel resort steps with sleep re-check" git push ``` ## Done-When - [ ] Each resort is processed by its own `"use step"` function - [ ] Steps run in parallel via `Promise.all` in the workflow - [ ] `sleep()` pauses the workflow between evaluation rounds - [ ] Workflow rechecks up to 3 times if no alerts trigger - [ ] `npx workflow web` shows parallel steps and sleep states ## Solution ```typescript title="workflows/evaluate-alerts.ts" {3,10-11,16-17,27-30,35-56} import { sleep } from 'workflow'; import type { Alert } from '$lib/schemas/alert'; interface EvaluateInput { alerts: Alert[]; recheckCount?: number; } interface AlertResult { alertId: string; resortId: string; triggered: boolean; } export default async function evaluateAlerts( { alerts, recheckCount = 0 }: EvaluateInput ) { "use workflow"; const alertsByResort = Object.groupBy(alerts, (a) => a.resortId); const resortIds = Object.keys(alertsByResort); // One step per resort, all in parallel const results = await Promise.all( resortIds.map((resortId) => evaluateResort(resortId, alertsByResort[resortId]!) ) ); const allResults = results.flat(); const triggered = allResults.filter((r) => r.triggered); console.log('[Workflow] Round complete', { round: recheckCount + 1, evaluated: allResults.length, triggered: triggered.length }); // If nothing triggered and we haven't hit the recheck limit, sleep and try again if (triggered.length === 0 && recheckCount < 3) { await sleep('30m'); return evaluateAlerts({ alerts, recheckCount: recheckCount + 1 }); } return { results: allResults, rounds: recheckCount + 1, triggered: triggered.length }; } async function evaluateResort( resortId: string, alerts: Alert[] ): Promise { "use step"; const { getResort } = await import('$lib/data/resorts'); const { fetchWeather } = await import('$lib/services/weather'); const { evaluateCondition } = await import('$lib/services/alerts'); const resort = getResort(resortId); if (!resort) return []; const weather = await fetchWeather(resort); return alerts.map((alert) => ({ alertId: alert.id, resortId, triggered: evaluateCondition(alert.condition, weather) })); } ``` The refactor changes two behaviors from lesson 3.1: **Parallel steps.** `evaluateResort` is its own `"use step"` function. The workflow dispatches one per resort via `Promise.all`. The Workflow SDK runs them concurrently, and each has its own retry budget. If Mammoth's weather API times out, only Mammoth retries. Steamboat's result is already saved. **Sleep.** `sleep('30m')` suspends the workflow without keeping a function active. After 30 minutes, the platform resumes the workflow from its recorded state. The recursive call to `evaluateAlerts` increments `recheckCount` and starts a new evaluation round. After three re-checks, the workflow returns its current results. \*\*Note: Use a shorter sleep for local testing\*\* While developing, change `sleep('30m')` to `sleep('10s')` so you can see re-check rounds without waiting half an hour. Switch back to `'30m'` before deploying. \*\*Note: Data is serialized between workflow and steps\*\* Arguments and return values are copied, not shared. If you modify an object inside a step, the workflow doesn't see the change. Always return the data you want the workflow to use. ## Troubleshooting \*\*Warning: Steps run sequentially instead of in parallel\*\* Make sure you're passing the step calls to `Promise.all`, not awaiting each one individually. `await evaluateResort(...)` inside a `for` loop runs them sequentially. `Promise.all(resortIds.map(...))` runs them in parallel. \*\*Warning: Sleep doesn't seem to work locally\*\* The local Workflow SDK processes steps synchronously. Short sleeps like `sleep('5s')` should work, but the timing may not be precise. Deploy to Vercel to test production sleep behavior where the workflow suspends and resumes. ## Advanced: Racing Steps Against a Timeout `Promise.race` lets you set a deadline on a group of steps: ```typescript import { sleep } from 'workflow'; const results = await Promise.race([ Promise.all( resortIds.map((id) => evaluateResort(id, alertsByResort[id]!)) ), sleep('30s').then(() => 'timeout' as const) ]); if (results === 'timeout') { console.warn('[Workflow] Evaluation timed out after 30s'); return { results: [], timedOut: true }; } ``` The workflow returns whatever finishes first: the actual results or the timeout. Useful when you'd rather return partial data than wait indefinitely. --- title: "Error Handling" description: "Handle errors in workflows using FatalError for permanent failures, RetryableError with retryAfter for transient failures, and getStepMetadata for attempt-aware backoff." canonical_url: "https://vercel.com/academy/svelte-on-vercel/workflow-error-handling" md_url: "https://vercel.com/academy/svelte-on-vercel/workflow-error-handling.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-08T23:18:27.859Z" content_type: "lesson" course: "svelte-on-vercel" course_title: "Svelte on Vercel" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Error Handling # Workflow Error Handling Right now, every failure in the workflow looks the same. The weather API times out? Step fails, retries three times, gives up. An alert references a resort called `narnia`? Step fails, retries three times with the same bad ID, gives up. Three wasted retries on something that was never going to work. The Workflow SDK provides two error classes for these cases. `FatalError` stops retries for permanent failures. `RetryableError` schedules another attempt after a delay. `getStepMetadata()` exposes the attempt number so you can back off instead of repeatedly hitting a struggling API. ## Outcome Add error classification to the `evaluateResort` step with `FatalError` for permanent failures, `RetryableError` with exponential backoff for transient failures, and `getStepMetadata()` for attempt-aware logic. ## Fast Track 1. Throw `FatalError` for invalid resort IDs (no point retrying) 2. Catch weather API failures and throw `RetryableError` with a `retryAfter` duration 3. Use `getStepMetadata().attempt` to calculate exponential backoff ## Three Kinds of Failure ``` Resort not found → FatalError "narnia doesn't exist. Stop trying." Weather API timeout → RetryableError "Open-Meteo is slow right now. Try again in 5 seconds." Unknown error → Default retry "Something unexpected happened. Retry with default timing." ``` | Error Type | Behavior | When to Use | | ---------------- | --------------------------------------------- | ------------------------------------------ | | `FatalError` | Immediately fails the step, skips all retries | Bad data, missing resources, auth failures | | `RetryableError` | Retries after the specified delay | API timeouts, rate limits, 503 errors | | Unhandled error | Retries with default timing (up to 3 times) | Unexpected failures | Without these classes, every error gets the same default retry behavior. That means three identical requests to a resort that doesn't exist. With error classification, the first failure is the last. ## Hands-on Exercise 3.3 Add error handling to the `evaluateResort` step: **Requirements:** 1. Import `FatalError`, `RetryableError`, and `getStepMetadata` from `workflow` 2. Throw `FatalError` when `getResort(resortId)` returns nothing (permanent failure) 3. Wrap `fetchWeather()` in try/catch and throw `RetryableError` on failure 4. Use `getStepMetadata().attempt` to calculate exponential backoff for the `retryAfter` option 5. Log the attempt number so you can track retries in server logs **Implementation hints:** - `FatalError` and `RetryableError` are imported from `workflow` - `RetryableError` accepts a second argument: `{ retryAfter: '5s' }` with a duration string, milliseconds as a number, or a `Date` - `getStepMetadata()` returns `{ attempt, stepId }`. The `attempt` count starts at 1 - Exponential backoff formula: `Math.min(1000 * 2^(attempt-1), 30000)` caps at 30 seconds - You can set a custom retry limit on a step function: `evaluateResort.maxRetries = 5` (6 total attempts) ## Try It 1. **Test with a valid resort (should work as before):** ```bash $ curl -X POST http://localhost:5173/api/workflow \ -H "Content-Type: application/json" \ -d '{"alerts": [{"id": "a1", "resortId": "mammoth", "condition": {"type": "conditions", "match": "powder"}, "originalQuery": "test", "createdAt": "2025-01-01", "triggered": false}]}' ``` No errors in the response. Workflow completes normally. 2. **Test with an invalid resort ID:** ```bash $ curl -X POST http://localhost:5173/api/workflow \ -H "Content-Type: application/json" \ -d '{"alerts": [{"id": "a1", "resortId": "narnia", "condition": {"type": "conditions", "match": "powder"}, "originalQuery": "test", "createdAt": "2025-01-01", "triggered": false}, {"id": "a2", "resortId": "steamboat", "condition": {"type": "conditions", "match": "powder"}, "originalQuery": "test", "createdAt": "2025-01-01", "triggered": false}]}' ``` Open `npx workflow web`. The `evaluateResort` step for `narnia` should show as immediately failed with no retries. The `steamboat` step should succeed normally. 3. **Check server logs:** ``` [Evaluate] Fatal: Resort not found: narnia [Workflow] Round complete { round: 1, evaluated: 1, triggered: 0 } ``` The fatal error is logged once. No retry attempts. 4. **Inspect in the dashboard:** ```bash npx workflow web ``` Click into the workflow run. You should see: - `evaluateResort (narnia)`: failed, 0 retries, `FatalError` - `evaluateResort (steamboat)`: completed successfully ## Commit ```bash git add -A git commit -m "feat(workflow): add FatalError and RetryableError handling" git push ``` ## Done-When - [ ] Invalid resort IDs throw `FatalError` and skip all retries - [ ] Weather API failures throw `RetryableError` with a `retryAfter` duration - [ ] `getStepMetadata().attempt` drives exponential backoff - [ ] `npx workflow web` shows fatal stops and retry attempts - [ ] Valid resorts still process successfully alongside failures ## Solution ```typescript title="workflows/evaluate-alerts.ts" {1,34-52} import { sleep, FatalError, RetryableError, getStepMetadata } from 'workflow'; import type { Alert } from '$lib/schemas/alert'; interface EvaluateInput { alerts: Alert[]; recheckCount?: number; } interface AlertResult { alertId: string; resortId: string; triggered: boolean; } export default async function evaluateAlerts( { alerts, recheckCount = 0 }: EvaluateInput ) { "use workflow"; const alertsByResort = Object.groupBy(alerts, (a) => a.resortId); const resortIds = Object.keys(alertsByResort); const results = await Promise.all( resortIds.map((resortId) => evaluateResort(resortId, alertsByResort[resortId]!) ) ); const allResults = results.flat(); const triggered = allResults.filter((r) => r.triggered); console.log('[Workflow] Round complete', { round: recheckCount + 1, evaluated: allResults.length, triggered: triggered.length }); if (triggered.length === 0 && recheckCount < 3) { await sleep('30m'); return evaluateAlerts({ alerts, recheckCount: recheckCount + 1 }); } return { results: allResults, rounds: recheckCount + 1, triggered: triggered.length }; } async function evaluateResort( resortId: string, alerts: Alert[] ): Promise { "use step"; const { attempt } = getStepMetadata(); const { getResort } = await import('$lib/data/resorts'); const { fetchWeather } = await import('$lib/services/weather'); const { evaluateCondition } = await import('$lib/services/alerts'); // Permanent failure: resort doesn't exist const resort = getResort(resortId); if (!resort) { console.error(`[Evaluate] Fatal: Resort not found: ${resortId}`); throw new FatalError(`Resort not found: ${resortId}`); } // Transient failure: weather API might be down let weather; try { weather = await fetchWeather(resort); } catch (error) { const backoff = Math.min(1000 * Math.pow(2, attempt - 1), 30000); console.warn( `[Evaluate] Weather fetch failed for ${resort.name}, attempt ${attempt}`, error ); throw new RetryableError( `Weather API failed for ${resort.name}`, { retryAfter: backoff } ); } return alerts.map((alert) => ({ alertId: alert.id, resortId, triggered: evaluateCondition(alert.condition, weather) })); } ``` The implementation adds three error-handling behaviors to lesson 3.2: **`FatalError` for bad resort IDs.** If `getResort()` returns nothing, there's no resort to evaluate. `FatalError` stops the step immediately with zero retries. In the dashboard, you'll see it marked as a permanent failure. **`RetryableError` for weather API failures.** The `fetchWeather()` call is wrapped in try/catch. When it fails, the step throws `RetryableError` with a `retryAfter` value. The Workflow SDK waits that long before the next attempt. Because `retryAfter` accepts milliseconds, the backoff calculation can be passed directly. **Exponential backoff with `getStepMetadata().attempt`.** The attempt number starts at 1. The formula `1000 * 2^(attempt-1)` gives us 1s, 2s, 4s, 8s, 16s, capped at 30s. This prevents hammering a struggling API with rapid retries. The workflow function itself doesn't change. It still uses `Promise.all` to dispatch parallel steps. `FatalError` and `RetryableError` only affect the individual step that threw them. Other steps continue independently. ## Troubleshooting \*\*Warning: FatalError doesn't stop retries\*\* Make sure you're importing `FatalError` from `workflow`, not defining your own class. The Workflow SDK checks the error prototype to determine behavior. A custom class with the same name won't work. \*\*Warning: RetryableError retries immediately instead of waiting\*\* Check the `retryAfter` value. It accepts a duration string (`'5s'`), milliseconds as a number (`5000`), or a `Date` object. If you pass a string that isn't a valid duration format, the delay may be ignored. ## Advanced: Custom Retry Limits By default, steps retry 3 times (4 total attempts). You can customize this per step: ```typescript async function evaluateResort(resortId: string, alerts: Alert[]) { "use step"; // ...step logic } // Allow more retries for flaky APIs evaluateResort.maxRetries = 5; // 6 total attempts ``` Set `maxRetries = 0` for steps that should never retry (one attempt only). Combine this with `FatalError` for steps where any failure is permanent. ## Advanced: Idempotency Keys `getStepMetadata()` also returns a `stepId` that's stable across retries. Use it as an idempotency key for external APIs: ```typescript async function sendNotification(userId: string, message: string) { "use step"; const { stepId } = getStepMetadata(); await fetch('https://api.notifications.example/send', { method: 'POST', headers: { 'Idempotency-Key': stepId }, body: JSON.stringify({ userId, message }) }); } ``` If the step retries, the same `stepId` is sent again. The external API sees the duplicate key and skips the second send. No double notifications, even with retries. --- title: "ISR" description: "Configure Incremental Static Regeneration (ISR) to serve cached pages while revalidating content in the background." canonical_url: "https://vercel.com/academy/svelte-on-vercel/isr" md_url: "https://vercel.com/academy/svelte-on-vercel/isr.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-08T23:18:27.890Z" content_type: "lesson" course: "svelte-on-vercel" course_title: "Svelte on Vercel" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # ISR # Incremental Static Regeneration The ski-alerts dashboard fetches live weather data for 5 resorts on every page load. That's 5 API calls per visitor. With 1,000 visitors per hour, you're making 5,000 weather API calls for data that changes maybe once every few minutes. ISR serves a cached version instantly and refreshes the data in the background. ## Outcome Enable ISR on the conditions dashboard so it serves cached pages and revalidates every 5 minutes. ## Fast Track 1. Export a `config` object with `isr.expiration` from `+page.server.ts` 2. Deploy to Vercel 3. Verify the page loads instantly from cache with background revalidation ## How ISR Works ``` First request: User → Vercel Edge → Run load() → Fetch weather → Render page → Cache result → Return to user Next request (within 5 minutes): User → Vercel Edge → Return cached page instantly (0ms) Request after expiration: User → Vercel Edge → Return stale cached page instantly → Background: Run load() → Fetch weather → Update cache ``` After the cache expires, Vercel serves the stale response while revalidating in the background. The request that triggers revalidation does not wait for the weather API. ## Hands-on Exercise 4.1 Enable ISR on the conditions dashboard: **Requirements:** 1. Uncomment the ISR config in `src/routes/+page.server.ts` 2. Set the expiration to 300 seconds (5 minutes) 3. Deploy to Vercel and verify caching behavior **Implementation hints:** - The config is already in the starter file as a comment, so uncomment it - ISR only works on Vercel (not in local dev), so you need to deploy to test it - The `fetchedAt` timestamp in the page data tells you when the data was actually fetched vs when you're seeing the cached version - Check the `x-vercel-cache` response header to see if the page was served from cache ## Try It 1. **Enable the ISR config:** ```typescript title="src/routes/+page.server.ts" {1-5} export const config = { isr: { expiration: 300 // Revalidate every 5 minutes } }; ``` 2. **Deploy and visit the page:** ```bash $ git add -A && git commit -m "feat(isr): enable 5-minute caching" && git push ``` 3. **Check response headers (first visit):** ``` x-vercel-cache: MISS ``` The page was generated fresh. 4. **Refresh the page:** ``` x-vercel-cache: HIT ``` Served from cache. Notice the "Last updated" timestamp stays the same. 5. **Wait 5 minutes and refresh:** ``` x-vercel-cache: STALE ``` You got the stale cached version instantly. Vercel is regenerating the page in the background. The next request will show `HIT` with a new timestamp. ## Commit ```bash git add -A git commit -m "feat(isr): enable 5-minute caching on conditions dashboard" git push ``` ## Done-When - [ ] `config.isr.expiration` is set to 300 in `+page.server.ts` - [ ] Deployed page shows `x-vercel-cache: HIT` on subsequent requests - [ ] "Last updated" timestamp stays the same between cached requests - [ ] After expiration, page revalidates in the background ## Solution ```typescript title="src/routes/+page.server.ts" import { resorts } from '$lib/data/resorts'; import { fetchAllConditions } from '$lib/services/weather'; import type { PageServerLoad } from './$types'; export const config = { isr: { expiration: 300 // Revalidate every 5 minutes } }; export const load: PageServerLoad = async () => { const conditions = await fetchAllConditions(resorts); return { conditions, fetchedAt: new Date().toISOString() }; }; ``` That's a two-line change from the starter: uncomment the config object. The `expiration: 300` means: - For 5 minutes after generation, serve the cached version - After 5 minutes, serve the stale version but regenerate in the background - The next request after regeneration gets the fresh version ## Troubleshooting \*\*Warning: x-vercel-cache always shows MISS\*\* ISR only works on deployed Vercel, not in local dev. Make sure you've deployed with `git push` and are testing against your production or preview URL, not `localhost:5173`. \*\*Warning: Page still shows old data after expiration\*\* This is how stale-while-revalidate works. The first request after expiration gets the stale page and triggers a background regeneration. The *next* request gets the fresh version. Refresh twice. ## Advanced: ISR Options **Bypass token** to force regeneration on demand: ```typescript export const config = { isr: { expiration: 300, bypassToken: 'my-secret-token' } }; ``` Then hit `https://your-app.vercel.app/?__prerender_bypass=my-secret-token` to force a fresh render. Useful for content updates that can't wait for expiration. **Per-route ISR** for different expiration on different pages: ```typescript // Dashboard: refresh every 5 minutes (weather changes slowly) // src/routes/+page.server.ts export const config = { isr: { expiration: 300 } }; // Alerts page: no ISR (user-specific data, no caching) // src/routes/alerts/+page.server.ts // (no config needed -defaults to dynamic rendering) ``` \*\*Note: ISR only applies to page server load\*\* API routes (`+server.ts`) are not affected by ISR. They always run dynamically. Use `Cache-Control` headers for API caching (covered in lesson 4.3). --- title: "Observability" description: "Implement observability and logging to monitor your SvelteKit application in production and debug issues effectively." canonical_url: "https://vercel.com/academy/svelte-on-vercel/svelte-observability" md_url: "https://vercel.com/academy/svelte-on-vercel/svelte-observability.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-08T23:18:27.905Z" content_type: "lesson" course: "svelte-on-vercel" course_title: "Svelte on Vercel" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Observability # Observability and Structured Logging When something breaks in production, "it's not working" isn't enough information. You need to know which endpoint failed, how long it took, what the input was, and whether the error is transient or permanent. Structured logging gives you that context. ## Outcome Add structured logging to the ski-alerts API endpoints so you can trace requests and debug issues in production. ## Fast Track 1. Use `console.log`, `console.warn`, and `console.error` with structured JSON data 2. Add request timing to API endpoints 3. Log at the right level: info for success, warn for retryable errors, error for fatal failures ## Log Levels Matter ``` console.log() → INFO → Normal operations, request timing, step completions console.warn() → WARN → Retryable errors, degraded state, slow responses console.error() → ERROR → Fatal errors, unrecoverable failures, data corruption ``` Vercel captures all three levels and displays them in the **Logs** tab of your project dashboard. You can filter by level, search by text, and correlate logs with specific deployments. ## Existing Logging in the Workflow The workflow steps from Section 3 already have some logging: ```typescript title="workflows/evaluate-alerts.ts" // Step completion (INFO) console.log('[Workflow] Round complete', { round: recheckCount + 1, evaluated: allResults.length, triggered: triggered.length }); // Retryable error (WARN) console.warn(`[Evaluate] Weather fetch failed for ${resort.name}, attempt ${attempt}`, error); // Fatal error (ERROR) console.error(`[Evaluate] Fatal: Resort not found: ${resortId}`); ``` These logs report individual failures but do not correlate events from the same request or record total processing time. ## Hands-on Exercise 4.2 Add structured logging with request context and timing to the API endpoints: **Requirements:** 1. Add a request ID to the workflow endpoint so you can correlate enqueued runs 2. Add a request ID and timing to the evaluate endpoint 3. Log the start and end of each evaluate request with duration 4. Use consistent prefixes (`[Workflow]`, `[Evaluate]`) for easy filtering **Implementation hints:** - Generate a request ID with `crypto.randomUUID()` and include it in every log for that request - Use `Date.now()` at start and end to calculate duration - Log input summary (number of alerts, which resorts) at the start of each request - Log output summary (results, triggered count, errors) at the end - Keep the objects small. Don't log entire alert arrays, just counts and IDs ## Try It 1. **Send a workflow request:** ```bash $ curl -X POST http://localhost:5173/api/workflow \ -H "Content-Type: application/json" \ -d '{"alerts": [{"id": "a1", "resortId": "mammoth", "condition": {"type": "conditions", "match": "powder"}, "originalQuery": "test", "createdAt": "2025-01-01", "triggered": false}]}' ``` 2. **Check the server logs for structured output:** ``` [Workflow] Enqueued { requestId: "abc-123", alertCount: 1, resorts: ["mammoth"] } [Workflow] Round complete { round: 1, evaluated: 1, triggered: 0 } ``` The first line comes from the route handler. The second comes from the workflow itself (added in Section 3). 3. **Test the evaluate endpoint with structured logging:** ```bash $ curl -X POST http://localhost:5173/api/evaluate \ -H "Content-Type: application/json" \ -d '{"alerts": [{"id": "a1", "resortId": "mammoth", "condition": {"type": "conditions", "match": "powder"}, "originalQuery": "test", "createdAt": "2025-01-01", "triggered": false}]}' ``` Logs should show start and end with duration: ``` [Evaluate] Started { requestId: "def-456", alertCount: 1, resorts: ["mammoth"] } [Evaluate] Completed { requestId: "def-456", duration: 523, evaluated: 1, triggered: 0 } ``` ## Commit ```bash git add -A git commit -m "feat(observability): add structured logging with request IDs" git push ``` ## Done-When - [ ] Workflow endpoint logs each enqueued run with a request ID and alert summary - [ ] Evaluate endpoint logs start and end of each request with duration - [ ] Each request has a unique request ID in all its log lines - [ ] Log levels match severity (log for success, warn for retryable, error for fatal) ## Solution The workflow route handler (`/api/workflow`) calls `start()` and returns a run ID; the workflow performs the evaluation separately. Log enqueue context in the route handler, step activity in the workflow file, and synchronous evaluation in the evaluate endpoint. **1. Add request tracking to the workflow route handler:** ```typescript title="src/routes/api/workflow/+server.ts" {5-6,14-18} import { json } from '@sveltejs/kit'; import { start } from 'workflow/api'; import evaluateAlerts from '../../../../workflows/evaluate-alerts'; import type { RequestHandler } from './$types'; export const POST: RequestHandler = async ({ request }) => { const requestId = crypto.randomUUID().slice(0, 8); const { alerts } = await request.json(); if (!alerts || !Array.isArray(alerts)) { return json({ error: 'alerts array required' }, { status: 400 }); } console.log('[Workflow] Enqueued', { requestId, alertCount: alerts.length, resorts: [...new Set(alerts.map((a: { resortId: string }) => a.resortId))] }); const run = await start(evaluateAlerts, [{ alerts }]); return json({ requestId, runId: run.runId, status: 'started' }); }; ``` The request ID correlates the enqueue event with later workflow output. Evaluation logging already exists in `workflows/evaluate-alerts.ts` from Section 3. **2. Add structured logging to the evaluate endpoint:** The `/api/evaluate` endpoint fetches weather synchronously, so add request timing and error context there: ```typescript title="src/routes/api/evaluate/+server.ts" {2-3,9-13,27-32} export const POST: RequestHandler = async ({ request }) => { const requestId = crypto.randomUUID().slice(0, 8); const startTime = Date.now(); const { alerts } = (await request.json()) as { alerts: Alert[] }; // ... validation and grouping unchanged ... console.log('[Evaluate] Started', { requestId, alertCount: alerts.length, resorts: [...alertsByResort.keys()] }); // ... existing evaluation loop ... // Add { requestId } to warn/error calls within the loop console.log('[Evaluate] Completed', { requestId, duration: Date.now() - startTime, evaluated: results.length, triggered: results.filter((r) => r.triggered).length }); return json({ /* ... unchanged ... */ }); }; ``` ## Troubleshooting \*\*Warning: Logs don't appear in the Vercel dashboard\*\* Vercel logs are per-deployment. Make sure you're looking at the correct deployment in the **Logs** tab, not a previous one. Logs also take a few seconds to appear after the request completes. \*\*Warning: requestId shows up in some log lines but not others\*\* Make sure `requestId` is defined at the top of the handler before any log calls. If you generate it after the first `console.log`, that first line won't have it. Define it right after `Date.now()`. ## Advanced: Vercel Log Drain For production monitoring, you can forward Vercel logs to external services. Go to your project's **Settings → Log Drains** to configure integrations with Datadog, Axiom, or other observability platforms. See the [Vercel Log Drains docs](https://vercel.com/docs/observability/log-drains) for setup instructions. --- title: "Performance" description: "Optimize your SvelteKit application's performance with caching strategies, bundle optimization, and runtime improvements." canonical_url: "https://vercel.com/academy/svelte-on-vercel/performance" md_url: "https://vercel.com/academy/svelte-on-vercel/performance.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-08T23:18:27.923Z" content_type: "lesson" course: "svelte-on-vercel" course_title: "Svelte on Vercel" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Performance # Performance Optimization The ski-alerts app works. But "works" and "fast" are different things. This lesson covers the patterns already in your codebase that make it fast, and adds caching headers to your API routes so repeated requests don't hit the weather API unnecessarily. ## Outcome Add `Cache-Control` headers to API endpoints and understand the parallel fetching patterns already in the app. ## Fast Track 1. Add `Cache-Control` headers to the evaluate endpoint 2. Understand the `Promise.all` pattern in `fetchAllConditions` 3. Know when to cache and when not to ## What's Already Fast The ski-alerts app already uses two performance patterns: parallel data fetching and ISR. ### Parallel Data Fetching The `fetchAllConditions` function in `src/lib/services/weather.ts` fetches weather for all 5 resorts in parallel: ```typescript title="src/lib/services/weather.ts" {2-5} export async function fetchAllConditions(resorts: Resort[]): Promise { const results = await Promise.all( resorts.map(async (resort) => { const weather = await fetchWeather(resort); return { resort, weather }; }) ); return results; } ``` Without `Promise.all`, fetching 5 resorts sequentially takes \~2.5 seconds (5 x 500ms each). With `Promise.all`, all 5 requests happen concurrently and the total time is \~500ms, the duration of the slowest single request. ``` Sequential: resort1 (500ms) → resort2 (500ms) → resort3 (500ms) → ... = ~2.5s Parallel: resort1 (500ms) resort2 (500ms) = ~500ms total resort3 (500ms) resort4 (500ms) resort5 (500ms) ``` ### ISR on the Dashboard From lesson 4.1, the dashboard uses ISR with a 5-minute expiration. Most visitors get an instant cached response instead of waiting for weather API calls. ## Hands-on Exercise 4.3 Add caching headers to the evaluate endpoint and review the app's existing performance patterns: **Requirements:** 1. Add `Cache-Control` headers to `GET` responses from the evaluate endpoint 2. Ensure `POST` endpoints are never cached (they have side effects) 3. Review and understand the parallel fetching pattern in the weather service **Implementation hints:** - Use `Cache-Control: public, s-maxage=60, stale-while-revalidate=300` for the evaluate endpoint. This caches for 1 minute on the CDN and serves stale for up to 5 minutes while revalidating - `s-maxage` controls CDN/edge caching; `max-age` controls browser caching - `stale-while-revalidate` serves the cached version while fetching a fresh one in the background - POST requests should never be cached, but you don't need to set headers because browsers and CDNs don't cache POST by default **What NOT to cache:** - `/api/chat`: streaming responses are unique per user - `/api/workflow`: side effects (evaluating and marking alerts) - POST `/api/evaluate`: the current implementation is POST, so it's not cached by default ## Try It 1. **Add a GET handler to the evaluate endpoint for cacheable responses:** You can add a simple GET endpoint that returns conditions for all resorts: ```typescript title="src/routes/api/evaluate/+server.ts" export const GET: RequestHandler = async () => { const conditions = await fetchAllConditions(resorts); return json( { resorts: conditions.map(({ resort, weather }) => ({ id: resort.id, name: resort.name, conditions: weather.conditions, temperature: weather.temperature, snowfall: weather.snowfall24h })), fetchedAt: new Date().toISOString() }, { headers: { 'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=300' } } ); }; ``` 2. **Deploy and test caching:** ```bash $ curl -I https://your-app.vercel.app/api/evaluate ``` Response headers: ``` cache-control: public, s-maxage=60, stale-while-revalidate=300 x-vercel-cache: MISS (first request) ``` Second request: ``` x-vercel-cache: HIT (served from edge cache) ``` 3. **Measure the difference:** - First request: \~500ms (weather API calls) - Cached request: \~5ms (edge cache hit) ## Commit ```bash git add -A git commit -m "feat(perf): add caching headers to evaluate endpoint" git push ``` ## Done-When - [ ] GET `/api/evaluate` returns data with `Cache-Control` headers - [ ] Subsequent requests show `x-vercel-cache: HIT` - [ ] POST endpoints remain uncached - [ ] You understand the parallel fetching pattern in `fetchAllConditions` ## Solution ```typescript title="src/routes/api/evaluate/+server.ts" {8-30} import { json } from '@sveltejs/kit'; import { resorts, getResort } from '$lib/data/resorts'; import { fetchWeather, fetchAllConditions } from '$lib/services/weather'; import { evaluateCondition } from '$lib/services/alerts'; import type { Alert } from '$lib/schemas/alert'; import type { RequestHandler } from './$types'; interface EvaluationResult { alertId: string; resortId: string; resortName: string; triggered: boolean; condition: Alert['condition']; weather: { temperature: number; snowfall: number; conditions: string; }; } // Cacheable GET endpoint for current conditions export const GET: RequestHandler = async () => { const conditions = await fetchAllConditions(resorts); return json( { resorts: conditions.map(({ resort, weather }) => ({ id: resort.id, name: resort.name, conditions: weather.conditions, temperature: weather.temperature, snowfall: weather.snowfall24h })), fetchedAt: new Date().toISOString() }, { headers: { 'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=300' } } ); }; // POST handler for evaluating specific alerts (not cached) export const POST: RequestHandler = async ({ request }) => { const requestId = crypto.randomUUID().slice(0, 8); const startTime = Date.now(); const { alerts } = (await request.json()) as { alerts: Alert[] }; if (!alerts || !Array.isArray(alerts)) { return json({ error: 'alerts array required' }, { status: 400 }); } const results: EvaluationResult[] = []; const alertsByResort = new Map(); for (const alert of alerts) { const existing = alertsByResort.get(alert.resortId) || []; existing.push(alert); alertsByResort.set(alert.resortId, existing); } console.log(`[Evaluate] Started`, { requestId, alertCount: alerts.length, resorts: [...alertsByResort.keys()] }); for (const [resortId, resortAlerts] of alertsByResort) { const resort = getResort(resortId); if (!resort) { console.warn(`[Evaluate] Resort not found: ${resortId}`, { requestId }); continue; } try { const weather = await fetchWeather(resort); for (const alert of resortAlerts) { const triggered = evaluateCondition(alert.condition, weather); results.push({ alertId: alert.id, resortId: alert.resortId, resortName: resort.name, triggered, condition: alert.condition, weather: { temperature: weather.temperature, snowfall: weather.snowfall24h, conditions: weather.conditions } }); } } catch (error) { console.error(`[Evaluate] Weather fetch failed for ${resort.name}:`, { requestId, error: String(error) }); } } console.log(`[Evaluate] Completed`, { requestId, duration: Date.now() - startTime, evaluated: results.length, triggered: results.filter((r) => r.triggered).length }); return json({ evaluated: results.length, triggered: results.filter((r) => r.triggered).length, results }); }; ``` ## Troubleshooting \*\*Warning: Cache-Control header not appearing in response\*\* Check that you're returning the headers in the second argument to `json()`. The syntax is `json(data, { headers: { 'Cache-Control': '...' } })`. If you put the headers object inside the data, they won't be set on the HTTP response. \*\*Warning: GET endpoint returns 405 Method Not Allowed\*\* Make sure you exported a named `GET` constant, not a default export. SvelteKit expects `export const GET: RequestHandler = async () => { ... }`. Also verify the file is `+server.ts`, not `+page.server.ts`. Page server files don't support custom HTTP method handlers. ## Cache-Control Cheat Sheet | Header | Where it caches | Duration | | ---------------------------- | --------------------------------- | --------------- | | `max-age=60` | Browser | 60 seconds | | `s-maxage=60` | CDN/Edge | 60 seconds | | `stale-while-revalidate=300` | CDN serves stale while refreshing | Up to 5 minutes | | `no-store` | Nowhere | Never cached | | `private` | Browser only | Not on CDN | For the ski-alerts app: - **Dashboard page**: ISR handles caching (lesson 4.1) - **GET /api/evaluate**: CDN-cached with `s-maxage=60` - **POST endpoints**: Not cached (default for POST) - **Streaming endpoints**: Not cacheable (unique per request) ## Advanced: Vercel Speed Insights For real user performance monitoring, add Vercel Speed Insights to track Core Web Vitals (LCP, INP, CLS) across your deployed app. See the [Speed Insights docs](https://vercel.com/docs/speed-insights) for setup instructions. The key metrics to watch for ski-alerts: - **LCP (Largest Contentful Paint)**: How fast the conditions dashboard renders. ISR keeps this fast - **INP (Interaction to Next Paint)**: How quickly the UI responds to interactions like sending a chat message. Streaming helps here - **CLS (Cumulative Layout Shift)**: Whether the page shifts as data loads. The fixed layout prevents this --- title: "What You Built" description: "A recap of the ski-alerts app and the Vercel platform features you integrated throughout the course." canonical_url: "https://vercel.com/academy/svelte-on-vercel/svelte-conclusion" md_url: "https://vercel.com/academy/svelte-on-vercel/svelte-conclusion.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-08T23:18:27.938Z" content_type: "lesson" course: "svelte-on-vercel" course_title: "Svelte on Vercel" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # What You Built # What You Built It's 5:47am and Grand Targhee just got 14 inches overnight. Your app handles what happens next. The streaming chat parses "powder at Targhee" into a structured alert, the background workflow evaluates conditions across five resorts without blocking, and the cached dashboard loads in milliseconds for every skier refreshing the page. ## What You Learned by Section **Deployment Foundations.** You configured `adapter-vercel`, set up environment variables across three scopes, used preview deployments for safe iteration, and pinned your Node.js runtime version. **AI Gateway.** You built streaming chat with `streamText()`, created tools with Valibot schemas for type-safe AI interactions, extracted structured data with `Output.object()`, and centralized your provider with usage tracking middleware. **Workflows.** You built durable workflows with the Workflow SDK, ran parallel steps with independent retries, scheduled re-checks with `sleep()`, and classified errors as `FatalError` or `RetryableError` with exponential backoff. **Production.** You configured ISR for fast cached pages, added structured logging with request IDs for observability, and set `Cache-Control` headers for CDN-level caching on API routes. ## Keep Building The ski-alerts app has natural extensions if you want to keep going: - **Persist alerts to a database.** Swap localStorage for Vercel KV or Postgres so alerts survive across devices. - **Add a cron trigger.** Use Vercel Cron Jobs to start the workflow on a schedule so alerts evaluate automatically. - **Send real notifications.** Wire triggered alert IDs to email or push notifications. - **Add more conditions.** Wind speed, visibility, lift status from resort APIs. ## Where to Go Next - **[SvelteKit docs](https://svelte.dev/docs/kit)**: Hooks, form actions, and advanced routing - **[AI SDK docs](https://sdk.vercel.ai)**: Agents, MCP tool integration, and the `Chat` class from `@ai-sdk/svelte` - **[Workflow SDK docs](https://workflow-sdk.dev)**: Hooks, webhooks, streaming, and the `@workflow/ai` agent integration - **[Vercel docs](https://vercel.com/docs)**: Cron Jobs, KV storage, and Fluid compute - **[Svelte 5 runes](https://svelte.dev/docs/svelte/what-are-runes)**: `$state`, `$derived`, `$effect`, and the reactivity model Choose one extension, implement its failure path, and deploy it as the next iteration of the ski-alerts app. --- title: "Nuxt on Vercel" description: "Translate your React and Next.js skills to Nuxt. Build a hot springs finder app using idiomatic Nuxt patterns, from reactivity to auth to deployment." canonical_url: "https://vercel.com/academy/nuxt-on-vercel" md_url: "https://vercel.com/academy/nuxt-on-vercel.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-09-22T04:50:24.083Z" content_type: "course" lessons: 18 estimated_time: lesson_urls: - "https://vercel.com/academy/nuxt-on-vercel/nuxt-project-setup.md" - "https://vercel.com/academy/nuxt-on-vercel/pages-and-routing.md" - "https://vercel.com/academy/nuxt-on-vercel/components-and-reactivity.md" - "https://vercel.com/academy/nuxt-on-vercel/layouts-and-navigation.md" - "https://vercel.com/academy/nuxt-on-vercel/server-routes.md" - "https://vercel.com/academy/nuxt-on-vercel/data-fetching.md" - "https://vercel.com/academy/nuxt-on-vercel/dynamic-routes.md" - "https://vercel.com/academy/nuxt-on-vercel/search-and-filtering.md" - "https://vercel.com/academy/nuxt-on-vercel/auth-setup.md" - "https://vercel.com/academy/nuxt-on-vercel/login-flow.md" - "https://vercel.com/academy/nuxt-on-vercel/route-protection.md" - "https://vercel.com/academy/nuxt-on-vercel/saving-favorites.md" - "https://vercel.com/academy/nuxt-on-vercel/visited-tracking.md" - "https://vercel.com/academy/nuxt-on-vercel/reviews.md" - "https://vercel.com/academy/nuxt-on-vercel/debugging-tools.md" - "https://vercel.com/academy/nuxt-on-vercel/rendering-modes.md" - "https://vercel.com/academy/nuxt-on-vercel/optimization.md" - "https://vercel.com/academy/nuxt-on-vercel/deploy-nuxt-to-vercel.md" --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Nuxt on Vercel You know React and Next.js, but your new codebase is full of `.vue` files. Translating familiar patterns syntax by syntax may produce working code while missing the conventions that make Nuxt productive. This course maps the React patterns you already know to their idiomatic Nuxt equivalents. The important differences are in the mental model: how data flows, how pages render, and where server state lives. You'll learn those differences by building the same feature in Nuxt and comparing it with the React approach. ## What you'll build You'll build **Hot Springs Finder**, a full-stack Nuxt app for discovering hot springs around the world. **Project foundations:** - Scaffold a Nuxt 4 app and map its structure to what you know from Next.js - Build pages, components, and layouts using Vue's reactivity model - Navigate between routes with file-based routing **Server-powered data:** - Create server routes that serve hot spring data - Fetch data with `useFetch` and `useAsyncData` - Build dynamic detail pages and server-side filtering **Authentication:** - Wire up `nuxt-auth-utils` with GitHub OAuth - Build login and logout flows with `useUserSession` - Protect routes with middleware **User features:** - Save favorite hot springs with optimistic UI - Track visited springs with personal stats - Add and display user reviews **Performance and deployment:** - Debug with Nuxt DevTools - Choose between SSR, CSR, and hybrid rendering - Optimize performance and deploy to Vercel ## Prerequisites - Comfortable with React (components, hooks, state management) - Familiar with Next.js basics (routing, data fetching) - Node.js and pnpm installed - A GitHub account (for OAuth) - A Vercel account (free tier works) ## Course sections ### Section 1: Foundations Get a Nuxt app running and learn how its file structure, routing, reactivity, and layouts compare to what you already know from React and Next.js. ### Section 2: Data & Server Build the public browsing experience: server routes, data fetching composables, dynamic pages, and search filtering. ### Section 3: Authentication Wire up GitHub OAuth with nuxt-auth-utils, build login flows, and protect routes with middleware. ### Section 4: User Features Add the authenticated experience: saving favorites, tracking visits, and leaving reviews on hot springs. ### Section 5: Performance & Polish Debug with Nuxt DevTools, understand rendering modes, optimize performance, and deploy to Vercel. --- title: "Project Setup" description: "Clone the starter repo, install dependencies, and tour the Nuxt 4 project structure with a side-by-side comparison to Next.js conventions." canonical_url: "https://vercel.com/academy/nuxt-on-vercel/nuxt-project-setup" md_url: "https://vercel.com/academy/nuxt-on-vercel/nuxt-project-setup.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-07T15:34:11.145Z" content_type: "lesson" course: "nuxt-on-vercel" course_title: "Nuxt on Vercel" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Project Setup # Set Up the Hot Springs Finder In Next.js, pages, layouts, API routes, loading states, and error boundaries all live under `app/`. Nuxt separates client code from server code, gives layouts their own directory, and auto-imports components. ## Outcome Scaffold a Nuxt 4 app with Tailwind CSS and understand how the project structure maps to Next.js. ## Fast Track 1. Clone the starter repo and install dependencies 2. Open the project and compare the file structure to Next.js 3. Start the dev server and confirm the home page loads ## Hands-on exercise 1.1 Clone the starter repo and get oriented in the Nuxt project structure. **Requirements:** 1. Clone the starter repo and install dependencies with `pnpm install` 2. Review the file structure, paying attention to `app/`, `server/`, and `nuxt.config.ts` 3. Start the dev server with `pnpm dev` and visit `http://localhost:3000` 4. Read through `nuxt.config.ts` and identify what each option does **Implementation hints:** - The `app/` directory is Nuxt 4's way of separating your client-side code from server code. In Next.js, everything lives under `app/` too, but in Nuxt, `server/` is a completely separate directory with its own auto-imports - `nuxt.config.ts` is the equivalent of `next.config.js`, but with a lot more built in. No need for separate Tailwind config files with v4 - Nuxt auto-imports Vue utilities (`ref`, `computed`, `watch`) and its own composables (`useFetch`, `useRoute`). No import statements needed for these Here's how the project structure maps to what you already know: ``` Nuxt 4 Next.js ───── ─────── app/ app/ pages/ (route files) components/ components/ layouts/ (layout.tsx files) composables/ hooks/ assets/ assets/ /public public/ or styles/ server/ app/api/ api/ (route handlers) routes/ (no equivalent) data/ (no equivalent) nuxt.config.ts next.config.js ``` Pay attention to these differences: - **`server/` is not inside `app/`.** In Next.js, your API routes live alongside your pages. In Nuxt, the server is a separate world powered by Nitro. It has its own auto-imports, its own utilities, and it doesn't know about Vue. - **No `layout.tsx` files inside page folders.** Nuxt uses a dedicated `layouts/` directory. As long as `app.vue` wraps `` in ``, `default.vue` is applied to every page automatically. - **Auto-imports cover several categories.** Components in `components/` (including subfolders), top-level composables in `composables/`, and Vue utilities are available without explicit imports. Composables in nested folders are not auto-imported. Let's look at the config file that holds it all together: ```typescript title="nuxt.config.ts" import tailwindcss from "@tailwindcss/vite"; export default defineNuxtConfig({ compatibilityDate: "2025-05-01", css: ["~/assets/css/main.css"], vite: { plugins: [tailwindcss()], }, }); ``` Note that the `~` in the CSS path is an alias for `app`. Nuxt 4 uses the `app/` directory for client code and `server/` for server code by default. If you've seen older Nuxt 3 projects with everything at the project root, that's the old way. Nuxt 4 enforces the separation out of the box. The `compatibilityDate` tells Nuxt which version of breaking-change behavior to use. Think of it like a snapshot: any breaking changes introduced after this date won't affect your app until you update the date. Tailwind v4 doesn't need a config file. The `@tailwindcss/vite` plugin handles everything, and `main.css` just imports it: ```css title="app/assets/css/main.css" @import "tailwindcss"; ``` You do not need `tailwind.config.js` or `postcss.config.js` for this setup. ## Try It Start the dev server: ```bash pnpm dev ``` Visit `http://localhost:3000`. You should see the home page with "Find your next soak" and a "Browse Hot Springs" button. Click "Browse" in the nav. The `/springs` page still shows its placeholder because data fetching comes later. \*\*Warning: If pnpm dev fails\*\* Make sure you ran `pnpm install` first. If you see a Vue version mismatch error, delete `node_modules` and `pnpm-lock.yaml`, then run `pnpm install` again. Nuxt 4 requires Vue 3.5+. \*\*Note: Port already in use?\*\* If port 3000 is taken, Nuxt will automatically try 3001. Check your terminal output for the actual URL. ## Commit ```bash git init && git add -A && git commit -m "feat(setup): scaffold Nuxt 4 app with Tailwind" ``` ## Done-When - [ ] `pnpm dev` starts without errors - [ ] Home page loads at `http://localhost:3000` with the Hot Springs Finder heading - [ ] You can navigate to `/springs` and see the placeholder page - [ ] You can explain why Nuxt 4 separates `server/` from `app/` ## Solution The starter repo already contains the complete setup for this lesson. Your project structure should look like this: ``` hot-springs-finder/ ├── app/ │ ├── app.vue │ ├── assets/css/main.css │ ├── components/ │ ├── composables/ │ ├── layouts/ │ │ └── default.vue │ └── pages/ │ ├── index.vue │ ├── favorites.vue │ ├── visited.vue │ ├── login.vue │ └── springs/ │ ├── index.vue │ └── [id].vue ├── server/ │ ├── api/ │ └── data/ │ └── springs.json ├── public/ ├── nuxt.config.ts ├── package.json └── tsconfig.json ``` ```vue title="app/app.vue" ``` ```vue title="app/pages/index.vue" ``` --- title: "Pages & Routing" description: "Create page files for the Hot Springs Finder, learn how Nuxt's file-based routing compares to Next.js, and navigate between pages with NuxtLink." canonical_url: "https://vercel.com/academy/nuxt-on-vercel/pages-and-routing" md_url: "https://vercel.com/academy/nuxt-on-vercel/pages-and-routing.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-07T15:34:11.174Z" content_type: "lesson" course: "nuxt-on-vercel" course_title: "Nuxt on Vercel" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Pages & Routing # Pages & Routing In Next.js, a route is a file called `page.tsx` inside a folder that matches the URL. Want `/about`? Create `app/about/page.tsx`. Want `/springs/[id]`? Create `app/springs/[id]/page.tsx`. The folder is the route, the file is always `page.tsx`. In Nuxt, the file defines the route. `/about` comes from `app/pages/about.vue`, with no wrapper folder or required `page` filename. The structure is compact once you know the naming rules. You'll build the Hot Springs Finder pages and compare both routing conventions. ## Outcome Create the route structure for the Hot Springs Finder with home, browse, and detail pages. ## Fast Track 1. Create page files in `app/pages/` for each route 2. Add `NuxtLink` navigation between pages 3. Verify routes resolve correctly in the browser ## Hands-on exercise 1.2 Build the page structure for the app. Right now, the starter has placeholder pages. We'll update them with real structure and connect them with navigation. **Requirements:** 1. Verify these pages exist in `app/pages/` and understand which URL each one maps to 2. Update the home page with a link to the browse page using `NuxtLink` 3. Navigate between pages and confirm the routes work **Implementation hints:** - `NuxtLink` is the equivalent of Next.js's `Link` component. It's auto-imported, so you don't need to import it - In Nuxt, `pages/springs/index.vue` maps to `/springs` and `pages/springs/[id].vue` maps to `/springs/:id`. In Next.js, those would be `app/springs/page.tsx` and `app/springs/[id]/page.tsx` - Square brackets for dynamic segments work the same way conceptually, but the file structure is flatter Here's the side-by-side for every route in our app: ``` URL Nuxt file Next.js file ─── ───────── ──────────── / pages/index.vue app/page.tsx /springs pages/springs/index.vue app/springs/page.tsx /springs/:id pages/springs/[id].vue app/springs/[id]/page.tsx /favorites pages/favorites.vue app/favorites/page.tsx /visited pages/visited.vue app/visited/page.tsx /login pages/login.vue app/login/page.tsx ``` Notice the pattern. Next.js always needs a folder. Nuxt lets you use either a folder with `index.vue` or a standalone file. `/favorites` doesn't need a `favorites/` folder because there are no nested routes under it. `/springs` uses a folder because it has a child route (`[id]`). Now let's look at how navigation works. In Next.js, you'd write: ```tsx import Link from "next/link"; Browse ``` In Nuxt, `NuxtLink` is auto-imported. It accepts `href`, but `to` is the convention across Vue Router and most Vue UI libraries. We'll use `to`: ```vue Browse ``` The home page already uses this pattern. Let's look at the link that takes users from the home page to the browse page: ```vue title="app/pages/index.vue" {12-17} ``` `NuxtLink` handles client-side navigation and prefetches the target route's code when the link enters the viewport. One thing that might catch you off guard: `useRoute()` in Nuxt returns a reactive route object, similar to Next.js's `useParams()` and `useSearchParams()` combined into one. We'll use this heavily when we build the detail page in a later lesson. ```vue ``` No import needed. `useRoute` is auto-imported like everything else in Nuxt. \*\*Note: Navigating in Nuxt\*\* `useRoute()` gives you the current route as a read-only object. For the equivalent of Next.js's `useRouter().push()`, prefer `navigateTo("/springs")`; it works on the server and supports redirects. Use `useRouter()` when you need the raw router instance. \*\*Warning: Catch-all routes look different\*\* Next.js uses `[...slug]/page.tsx` for a catch-all route, while Nuxt uses `[...slug].vue`. This project does not need one, but the syntax is useful when translating routes. ## Try It Start the dev server if it's not already running: ```bash pnpm dev ``` 1. Visit `http://localhost:3000`. Click "Browse Hot Springs." You should land on `/springs` 2. Check the URL bar. Click the browser back button. You should return to `/` without a full page reload 3. Visit `http://localhost:3000/springs/breitenbush-hot-springs` directly. You should see the detail page placeholder 4. Visit `http://localhost:3000/favorites` and `http://localhost:3000/visited`. Both should render their placeholder content All six routes should resolve without 404 errors. ## Commit ```bash git add -A && git commit -m "feat(routing): verify page routes and NuxtLink navigation" ``` ## Done-When - [ ] All six routes (`/`, `/springs`, `/springs/:id`, `/favorites`, `/visited`, `/login`) render without errors - [ ] Clicking `NuxtLink` navigates without full page reloads - [ ] You can explain when to use `useRoute()`, `navigateTo()`, and `useRouter()` in Nuxt - [ ] You can map any Next.js route file to its Nuxt equivalent ## Solution The starter already contains all the page files. The key files for routing are: ``` app/pages/ ├── index.vue → / ├── favorites.vue → /favorites ├── visited.vue → /visited ├── login.vue → /login └── springs/ ├── index.vue → /springs └── [id].vue → /springs/:id ``` ```vue title="app/pages/index.vue" ``` ```vue title="app/pages/springs/index.vue" ``` ```vue title="app/pages/springs/[id].vue" ``` --- title: "Components & Reactivity" description: "Create a reusable SpringCard component with props, computed values, and template bindings. Compare Vue's reactivity model to React's useState and useEffect patterns." canonical_url: "https://vercel.com/academy/nuxt-on-vercel/components-and-reactivity" md_url: "https://vercel.com/academy/nuxt-on-vercel/components-and-reactivity.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-07T15:34:11.201Z" content_type: "lesson" course: "nuxt-on-vercel" course_title: "Nuxt on Vercel" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Components & Reactivity # Components & Reactivity React components use `useState` for state, `useMemo` for cached derived values, and `useEffect` for side effects. Their mental model centers on component renders. Vue tracks reactive values directly. Derived values use `computed`, and watchers handle effects tied to specific values. Vue records those dependencies without an explicit dependency array. The comparison helps, but Vue's dependency tracking is the model to learn. ## Outcome Build a `SpringCard` component with typed props, computed values, and conditional styling. ## Fast Track 1. Define a `Spring` type in `app/types/spring.ts` 2. Create `SpringCard.vue` in `app/components/` with props and a computed value 3. Verify the component renders spring data with the correct styling ## Hands-on exercise 1.3 Build the `SpringCard` component that we'll use on the browse page to display each hot spring. **Requirements:** 1. Create a `Spring` TypeScript interface in `app/types/spring.ts` 2. Create `app/components/SpringCard.vue` that accepts a `spring` prop 3. Display the spring's name, truncated description, location, temperature range, and elevation 4. Show a colored badge for the spring type (wild, developed, resort) 5. Make the entire card a link to the spring's detail page **Implementation hints:** - In Vue, props are defined with `defineProps`. The generic syntax `defineProps<{ spring: Spring }>()` gives you type safety without a separate PropTypes library - `computed()` is Vue's equivalent of `useMemo`, but you don't pass a dependency array. Vue tracks dependencies automatically - Components in `app/components/` are auto-imported. No need to import `SpringCard` when you use it later - Template expressions use double curly braces `{{ }}` instead of JSX's single braces `{}` Start with the type. As in React, define a TypeScript interface in a types file: ```typescript title="app/types/spring.ts" export interface Spring { id: string; name: string; description: string; location: { region: string; country: string; lat: number; lng: number; }; temperature: { min: number; max: number; }; type: "wild" | "developed" | "resort"; features: string[]; elevation: number; imageUrl: string; } ``` This interface matches each hot spring in the JSON data. Now let's build the component. In React, you'd write something like this: ```tsx // React version: for comparison only interface SpringCardProps { spring: Spring; } function SpringCard({ spring }: SpringCardProps) { const temperatureLabel = useMemo( () => `${spring.temperature.min}–${spring.temperature.max}°F`, [spring.temperature.min, spring.temperature.max] ); return ...; } ``` Here's the Vue version: ```vue title="app/components/SpringCard.vue" ``` `defineProps` replaces the destructured React function parameter. `computed` serves the role of `useMemo` without a dependency array. Vue tracks the access to `props.spring.temperature` and updates `temperatureLabel` when that data changes. Now the template. This is where Vue diverges most from React: ```vue title="app/components/SpringCard.vue" ``` The colon prefix (`:to`, `:class`) is Vue's shorthand for dynamic attribute binding. `:to` means "evaluate this as JavaScript." Without the colon, it's a plain string. If you've been writing `href={...}` in JSX, the colon is the Vue equivalent of those curly braces. You can use both `class` and `:class` on the same element. Vue merges them. The static Tailwind classes stay in `class`, and the dynamic type color comes from `:class`. In React, you'd need a template literal or a library like `clsx` to combine them. \*\*Note: ref vs computed vs plain\*\* A useful translation is `ref()` to `useState()`, `computed()` to `useMemo()`, and `watch()` to a dependency-based `useEffect()`. Use plain variables for values that never change. \*\*Warning: Props are read-only\*\* Don't try to reassign `props.spring`. Vue props are read-only, just like React props. If you need to transform prop data, use `computed`. If you need local mutable state derived from a prop, use `ref` with an initial value. ## Try It We can't render the component on the browse page yet because we haven't wired up data fetching. But we can verify the component file exists and has no syntax errors. Check that the dev server shows no errors after creating both files. If you see a warning about unused components, that's fine. Nuxt knows `SpringCard` exists but nothing is using it yet. To preview the component, you can temporarily hardcode a spring in the browse page: ```vue title="app/pages/springs/index.vue" ``` You should see a card with "Breitenbush Hot Springs," a "developed" badge in sky blue, the temperature range, and the location. Click it and you'll navigate to `/springs/breitenbush-hot-springs`. Remove the test code when you're done. We'll wire up real data in Section 2. ## Commit ```bash git add -A && git commit -m "feat(components): add Spring type and SpringCard component" ``` ## Done-When - [ ] `app/types/spring.ts` defines the `Spring` interface with all fields - [ ] `app/components/SpringCard.vue` renders a card with name, description, location, temperature, and type badge - [ ] The card links to `/springs/:id` using `NuxtLink` - [ ] You can explain why `computed` doesn't need a dependency array in Vue ## Solution ```typescript title="app/types/spring.ts" export interface Spring { id: string; name: string; description: string; location: { region: string; country: string; lat: number; lng: number; }; temperature: { min: number; max: number; }; type: "wild" | "developed" | "resort"; features: string[]; elevation: number; imageUrl: string; } ``` ```vue title="app/components/SpringCard.vue" ``` --- title: "Layouts & Navigation" description: "Build the default layout with a navigation bar and footer, learn how Nuxt layouts differ from Next.js layout files, and wire up NuxtLink for client-side navigation." canonical_url: "https://vercel.com/academy/nuxt-on-vercel/layouts-and-navigation" md_url: "https://vercel.com/academy/nuxt-on-vercel/layouts-and-navigation.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-07T15:34:11.228Z" content_type: "lesson" course: "nuxt-on-vercel" course_title: "Nuxt on Vercel" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Layouts & Navigation # Layouts & Navigation In Next.js, layouts are `layout.tsx` files that live next to the routes they wrap. You can nest them, and each one inherits from its parent. The mental model is inheritance: every route composes its layout from the chain of `layout.tsx` files above it in the folder tree. Nuxt uses named layouts and slots. Layouts live in `app/layouts/`, and every page uses `default.vue` unless it selects another one. Layout selection is explicit rather than inherited through route folders. The starter includes a basic layout. You'll inspect its wiring and update the navigation for the pages built so far. ## Outcome Build a shared layout with a header, navigation links, and a footer that wraps every page. ## Fast Track 1. Review `app/layouts/default.vue` and understand the `` pattern 2. Add navigation links to all public pages 3. Verify the layout persists across page transitions ## Hands-on exercise 1.4 Update the default layout to include navigation links to the browse page and a placeholder for auth links we'll add later. **Requirements:** 1. Review how `app.vue` connects to the layout system via `` and `` 2. Update `app/layouts/default.vue` with a header containing the site title and navigation links 3. Include a `Browse` link that points to `/springs` 4. Add a placeholder comment where auth links will go in Section 3 5. Include a footer **Implementation hints:** - `` in a layout is where the page content renders. It's the equivalent of the `{children}` prop in a Next.js `layout.tsx` - `` in `app.vue` tells Nuxt to use the layout system. `` renders the matched page inside that layout - Every page gets the default layout automatically. You don't need to import it or specify it unless you want a different layout Let's start with `app.vue`, the entry point: ```vue title="app/app.vue" ``` `NuxtLayout` selects the layout, defaulting to `default.vue`, and `NuxtPage` renders the matched page inside it. Next.js wires layouts implicitly through the folder tree. Nuxt opts into the layout system through `app.vue`, so removing `NuxtLayout` would render pages without a layout. Now the layout itself. Here's the React equivalent, for orientation: ```tsx // Next.js layout.tsx: for comparison export default function RootLayout({ children }: { children: React.ReactNode }) { return (
{children}
...
); } ``` And the Nuxt version: ```vue title="app/layouts/default.vue" ``` The matched page renders inside ``, which corresponds to `{children}` in a React layout. Vue's dedicated slot elements can also support multiple named insertion points, although this project only uses the default slot. Notice there's no ` ``` `useFetch` handles the request, caching, SSR serialization, and reactive updates. The `data` ref changes when the response arrives, and `status` reports the request lifecycle. The template uses `v-if` and `v-for` to handle the three states: loading, results, and empty: ```vue title="app/pages/springs/index.vue" ``` The template introduces a few Vue conventions: `v-for="spring in springs"` replaces `springs.map((spring) => ...)`. Put `:key` on the element with `v-for`. The right side accepts JavaScript expressions, but use `computed` for substantial transformations so Vue can cache the result until its dependencies change. `v-if` / `v-else-if` / `v-else` must be on adjacent sibling elements. If you put a `
` between `v-if` and `v-else`, Vue won't connect them. This catches people coming from JSX where ternaries can span any distance. The `{{ springs?.length ?? 0 }}` expression works because Vue template expressions support most JavaScript. Optional chaining, nullish coalescing, ternaries, method calls. What they don't support: statements. No `if`, no `for`, no variable declarations inside `{{ }}`. \*\*Note: useFetch vs $fetch\*\* `useFetch` is for components. It integrates with SSR, deduplicates requests, and returns reactive refs. `$fetch` is for imperative calls: event handlers, utility functions, anywhere you'd use plain `fetch()`. We'll use `$fetch` later when we build favorites and reviews. \*\*Warning: Don't destructure the data ref\*\* `const { data: springs } = useFetch(...)` gives you a ref. Access it as `springs.value` in script, or plain `springs` in the template. If you destructure deeper (`const { data: { value: springs } }`), you'll lose reactivity and the template won't update. ## Try It Start the dev server and visit `http://localhost:3000/springs`. You should see: 1. "Browse Hot Springs" heading with "17 springs found" 2. A two-column grid of spring cards 3. Each card shows the name, truncated description, location, temperature, and a type badge 4. Clicking a card navigates to `/springs/[id]` (still a placeholder page) Refresh the page. The springs should appear instantly because `useFetch` runs during SSR and the data is embedded in the HTML payload. Open your browser's Network tab and you won't see a separate API call on the initial page load. Navigate away and return. Client-side navigation triggers a fresh API call, so it appears in the Network tab even though the page uses the same composable. ## Commit ```bash git add -A && git commit -m "feat(browse): wire up browse page with useFetch and SpringCard" ``` ## Done-When - [ ] The browse page loads and displays all 17 hot springs in a grid - [ ] Loading state shows "Loading springs..." briefly on client-side navigation - [ ] Each spring renders as a `SpringCard` with name, description, location, and type - [ ] You can explain the difference between `useFetch` and `$fetch` ## Solution ```vue title="app/pages/springs/index.vue" ``` --- title: "Dynamic Routes" description: "Create a dynamic detail page using route parameters, fetch individual spring data with useFetch, and handle 404 errors for missing springs." canonical_url: "https://vercel.com/academy/nuxt-on-vercel/dynamic-routes" md_url: "https://vercel.com/academy/nuxt-on-vercel/dynamic-routes.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-07T15:34:11.330Z" content_type: "lesson" course: "nuxt-on-vercel" course_title: "Nuxt on Vercel" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Dynamic Routes # Dynamic Routes A detail page maps one URL to one record and returns a 404 when that record does not exist. In Next.js, this pattern uses `params` and a Server Component. The Nuxt version combines `useRoute()`, `useFetch`, and `createError`. ## Outcome Build a detail page that displays full information for a single hot spring. ## Fast Track 1. Create the server route `server/api/springs/[id].get.ts` that returns a single spring 2. Update `app/pages/springs/[id].vue` to fetch and display the spring 3. Handle the 404 case when a spring doesn't exist ## Hands-on exercise 2.3 Build both the server route and the page for viewing a single hot spring. **Requirements:** 1. Create `server/api/springs/[id].get.ts` that finds a spring by ID and returns it 2. Throw a 404 error if the spring doesn't exist 3. Update `app/pages/springs/[id].vue` to fetch the spring and display its full details 4. Show location, temperature, elevation, and features 5. Include a back link to the browse page **Implementation hints:** - `getRouterParam(event, "id")` extracts route params in server routes. It's the server equivalent of `useRoute().params.id` - `createError({ statusCode: 404 })` throws an error that Nuxt catches and renders as an error page - `await useFetch(...)` with `await` blocks page rendering until the data loads. Without `await`, the page renders immediately with null data Let's start with the server route. We need to find a single spring by its ID: ```typescript title="server/api/springs/[id].get.ts" import type { Spring } from "~/types/spring"; import springs from "~/server/data/springs.json"; export default defineEventHandler((event) => { const id = getRouterParam(event, "id"); const spring = (springs as Spring[]).find((s) => s.id === id); if (!spring) { throw createError({ statusCode: 404, statusMessage: "Spring not found", }); } return spring; }); ``` `getRouterParam` pulls the `id` from the URL. If someone visits `/api/springs/breitenbush-hot-springs`, `id` is `"breitenbush-hot-springs"`. If the spring doesn't exist, we throw a 404. In Next.js, you'd return `NextResponse.json({ error: "Not found" }, { status: 404 })`. Nuxt's `createError` is more concise and integrates with the error page system. Next, build the detail page: ```vue title="app/pages/springs/[id].vue" ``` With `await useFetch`, Nuxt waits for the data before rendering the page. Without `await`, `spring` starts as `null` and the template needs a loading state. This detail page waits because all of its content depends on the response. The `if (error.value)` check catches both network errors and our 404 from the server route. `createError` on the client side triggers Nuxt's error page, which shows a full-page error with the status code and message. The template renders the spring's details in a structured layout: ```vue title="app/pages/springs/[id].vue" ``` The `v-if="spring"` guard at the top is a safety net. Even though `await useFetch` should guarantee the data exists, TypeScript's type narrowing doesn't know that. The `v-if` satisfies both TypeScript and the edge case where someone navigates directly to a broken URL. \*\*Note: await changes navigation behavior\*\* Using `await useFetch(...)` in a page means Nuxt waits for the data before transitioning to the page. The user stays on the previous page until the fetch completes. If you want the page to render immediately with a loading state, drop the `await` and check `status` in the template, like we did on the browse page. \*\*Warning: Route params are strings\*\* `route.params.id` is always a string, even if it looks like a number. If your IDs were numeric, you'd need to parse them. Our spring IDs are slugs, so this isn't an issue, but it catches people who switch from numeric to slug-based routes. ## Try It Visit `http://localhost:3000/springs/breitenbush-hot-springs`. You should see: 1. A back link to the browse page 2. The spring name ("Breitenbush Hot Springs") with a "developed" badge 3. The full description 4. A three-column grid showing location, temperature range, and elevation 5. Feature tags (clothing-optional, forest-setting, riverside, etc.) Click the back link. You should return to the browse page with all springs loaded. Now try a URL that doesn't exist: `http://localhost:3000/springs/nonexistent-spring`. You should see Nuxt's error page with a 404 status. ## Commit ```bash git add -A && git commit -m "feat(detail): add single spring API route and detail page" ``` ## Done-When - [ ] `/api/springs/breitenbush-hot-springs` returns a single spring as JSON - [ ] `/api/springs/nonexistent-spring` returns a 404 error - [ ] The detail page displays full spring information including features - [ ] The back link navigates to the browse page without a full page reload ## Solution ```typescript title="server/api/springs/[id].get.ts" import type { Spring } from "~/types/spring"; import springs from "~/server/data/springs.json"; export default defineEventHandler((event) => { const id = getRouterParam(event, "id"); const spring = (springs as Spring[]).find((s) => s.id === id); if (!spring) { throw createError({ statusCode: 404, statusMessage: "Spring not found", }); } return spring; }); ``` ```vue title="app/pages/springs/[id].vue" ``` --- title: "Search & Filtering" description: "Add query parameter support to the springs API, build reactive filters on the browse page, and learn how useFetch automatically refetches when query parameters change." canonical_url: "https://vercel.com/academy/nuxt-on-vercel/search-and-filtering" md_url: "https://vercel.com/academy/nuxt-on-vercel/search-and-filtering.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-07T15:34:11.355Z" content_type: "lesson" course: "nuxt-on-vercel" course_title: "Nuxt on Vercel" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Search & Filtering # Search & Filtering The browse page needs to filter springs by region, type, and search term. Changes to those values should trigger an API request with the corresponding query parameters. In Next.js, one approach uses `useSearchParams` and `router.push`, followed by a client refetch. A Server Component can instead read the parameters on each request. In Nuxt, `useFetch` accepts a reactive `query` option and refetches when its values change. The server route reads those parameters with `getQuery`. ## Outcome Add search and filter controls to the browse page that filter springs by region, type, and keyword. ## Fast Track 1. Add query parameter parsing to the springs server route 2. Create reactive filter refs and a computed query object on the browse page 3. Pass the query object to `useFetch` and verify live filtering ## Hands-on exercise 2.4 Add filtering to both the server route and the browse page. **Requirements:** 1. Update `server/api/springs/index.get.ts` to filter by `region`, `type`, and `search` query parameters 2. Add `ref` values for search, region, and type on the browse page 3. Create a `computed` object that builds query parameters from the filter refs 4. Pass the computed query to `useFetch` so it refetches automatically 5. Add filter UI: a text input for search, dropdowns for region and type, and a clear button **Implementation hints:** - `getQuery(event)` returns all query parameters as an object. `?region=Iceland&type=wild` becomes `{ region: "Iceland", type: "wild" }` - `useFetch` accepts a `query` option that can be a reactive ref or computed. When it changes, Nuxt refetches - Filters should be case-insensitive on the server - Empty string values should be excluded from the query object so they don't get sent as `?region=` Let's update the server route first. Right now it returns everything. We need it to filter based on query parameters: ```typescript title="server/api/springs/index.get.ts" {4-33} import type { Spring } from "~/types/spring"; import springs from "~/server/data/springs.json"; export default defineEventHandler((event) => { const query = getQuery(event); let results = springs as Spring[]; // Filter by region if (query.region && typeof query.region === "string") { results = results.filter( (s) => s.location.region.toLowerCase() === query.region!.toString().toLowerCase() ); } // Filter by type if (query.type && typeof query.type === "string") { results = results.filter((s) => s.type === query.type); } // Search by name or description if (query.search && typeof query.search === "string") { const term = query.search.toLowerCase(); results = results.filter( (s) => s.name.toLowerCase().includes(term) || s.description.toLowerCase().includes(term) ); } return results; }); ``` Each filter applies only when its parameter exists. A request with no parameters returns all 17 springs. `?region=Iceland` returns two, and `?search=cave` returns Goldmeyer. Parameters compose, so adding `type=wild` narrows the result further. The browse page needs three refs, a computed query object, and a `useFetch` call that reacts to that object: ```vue title="app/pages/springs/index.vue" ``` When `search`, `region`, or `type` changes, `queryParams` recomputes and `useFetch` refetches. Vue's dependency tracking connects the state to the request. `ref("")` corresponds to `useState("")`, but scripts access the value through `.value`. Use `search.value = "cave"` in script and `{{ search }}` in the template, where refs are automatically unwrapped. Now the filter UI: ```vue title="app/pages/springs/index.vue" ``` `v-model` is Vue's two-way binding. It replaces the `value` + `onChange` pattern in React. `v-model="search"` means: set the input's value to `search`, and update `search` when the user types. One directive does both jobs. The clear button uses `@click`, shorthand for `v-on:click`, to reset the refs. Those assignments recompute `queryParams` and trigger an unfiltered request. \*\*Note: v-model is two-way binding\*\* In React, form inputs are controlled with `value` + `onChange`. In Vue, `v-model` handles both directions. It works on inputs, selects, textareas, and custom components. When you see `v-model`, think "React controlled component in one attribute." \*\*Warning: useFetch refetches on every keystroke\*\* Since `search` is a `ref` wired directly to the input via `v-model`, every keystroke updates the ref, which recomputes queryParams, which triggers a refetch. For a local JSON file this is fine. For a real database, you'd want to debounce. Nuxt doesn't include a debounce utility, but you can wrap the search ref with a `watchDebounced` from VueUse or roll your own. ## Try It Visit `http://localhost:3000/springs` and try the filters: 1. Select "Iceland" from the region dropdown. You should see 2 springs: Blue Lagoon and Landmannalaugar 2. Change the type to "wild." Only Landmannalaugar should remain 3. Clear the filters. All 17 springs return 4. Type "cave" in the search box. Goldmeyer Hot Springs should appear (its description mentions a cave pool) 5. Type "taco" in the search box. No results. The empty state message appears Watch the spring count update in real time as you type and select filters. The URL stays the same (filters live in component state, not the URL), but the API calls update with the new query parameters. ## Commit ```bash git add -A && git commit -m "feat(search): add server-side filtering and browse page controls" ``` ## Done-When - [ ] `/api/springs?region=Iceland` returns 2 springs - [ ] `/api/springs?type=wild` returns only wild springs - [ ] `/api/springs?search=cave` returns Goldmeyer - [ ] Filters on the browse page update results in real time without manual refetching - [ ] The clear button resets all filters and shows all 17 springs ## Solution ```typescript title="server/api/springs/index.get.ts" import type { Spring } from "~/types/spring"; import springs from "~/server/data/springs.json"; export default defineEventHandler((event) => { const query = getQuery(event); let results = springs as Spring[]; if (query.region && typeof query.region === "string") { results = results.filter( (s) => s.location.region.toLowerCase() === query.region!.toString().toLowerCase() ); } if (query.type && typeof query.type === "string") { results = results.filter((s) => s.type === query.type); } if (query.search && typeof query.search === "string") { const term = query.search.toLowerCase(); results = results.filter( (s) => s.name.toLowerCase().includes(term) || s.description.toLowerCase().includes(term) ); } return results; }); ``` ```vue title="app/pages/springs/index.vue" ``` --- title: "Auth Setup" description: "Install the nuxt-auth-utils module, register a GitHub OAuth app, configure environment variables, and create the OAuth callback handler." canonical_url: "https://vercel.com/academy/nuxt-on-vercel/auth-setup" md_url: "https://vercel.com/academy/nuxt-on-vercel/auth-setup.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-07T15:34:11.408Z" content_type: "lesson" course: "nuxt-on-vercel" course_title: "Nuxt on Vercel" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Auth Setup # Auth Setup Next.js applications can use libraries such as NextAuth, Clerk, or Lucia, each with its own provider and session configuration. This project uses `nuxt-auth-utils`, which provides session handling and OAuth helpers. You'll add the module, create a callback route, and configure the required environment variables. ## Outcome Install `nuxt-auth-utils`, register a GitHub OAuth app, and create the server-side callback handler. ## Fast Track 1. Install `nuxt-auth-utils` and add it to `nuxt.config.ts` 2. Register a GitHub OAuth app and set environment variables 3. Create the GitHub OAuth handler in `server/routes/auth/github.get.ts` ## Hands-on exercise 3.1 Wire up GitHub OAuth from scratch. **Requirements:** 1. Install `nuxt-auth-utils` and add it to the `modules` array in `nuxt.config.ts` 2. Register a new OAuth app on GitHub with the callback URL `http://localhost:3000/auth/github` 3. Create a `.env` file with `NUXT_OAUTH_GITHUB_CLIENT_ID`, `NUXT_OAUTH_GITHUB_CLIENT_SECRET`, and `NUXT_SESSION_PASSWORD` 4. Create `server/routes/auth/github.get.ts` that handles the OAuth callback 5. On success, store the user's login, avatar URL, and ID in the session **Implementation hints:** - `nuxt-auth-utils` auto-imports `defineOAuthGitHubEventHandler`, `setUserSession`, `getUserSession`, and `requireUserSession` in server routes - It also auto-imports `useUserSession` in Vue components - The module reads `NUXT_OAUTH_GITHUB_CLIENT_ID` and `NUXT_OAUTH_GITHUB_CLIENT_SECRET` automatically. No config mapping needed - `NUXT_SESSION_PASSWORD` must be at least 32 characters. It encrypts the session cookie - Server routes in `server/routes/` (not `server/api/`) map directly to URLs. `server/routes/auth/github.get.ts` becomes `/auth/github` First, update the Nuxt config to include the module: ```typescript title="nuxt.config.ts" {6} import tailwindcss from "@tailwindcss/vite"; export default defineNuxtConfig({ compatibilityDate: "2025-05-01", modules: ["nuxt-auth-utils"], css: ["~/assets/css/main.css"], vite: { plugins: [tailwindcss()], }, }); ``` The module handles session management, cookie encryption, OAuth flows, and composable auto-imports. The comparable Next.js setup would include the auth configuration, route handler, provider, and session provider component. Next, register a GitHub OAuth app. Go to GitHub Settings > Developer settings > OAuth Apps > New OAuth App: - **Application name:** Hot Springs Finder (Dev) - **Homepage URL:** `http://localhost:3000` - **Authorization callback URL:** `http://localhost:3000/auth/github` GitHub gives you a Client ID and lets you generate a Client Secret. Put them in a `.env` file: ```bash title=".env" NUXT_OAUTH_GITHUB_CLIENT_ID=your_client_id_here NUXT_OAUTH_GITHUB_CLIENT_SECRET=your_client_secret_here NUXT_SESSION_PASSWORD=a-random-string-at-least-32-characters-long ``` `nuxt-auth-utils` reads the `NUXT_OAUTH_*` variables by convention, so this provider does not need a separate config object. Now the OAuth handler. This is the route GitHub redirects to after the user authorizes your app: ```typescript title="server/routes/auth/github.get.ts" export default defineOAuthGitHubEventHandler({ async onSuccess(event, { user }) { await setUserSession(event, { user: { login: user.login, avatar_url: user.avatar_url, id: user.id, }, }); return sendRedirect(event, "/springs"); }, onError(event, error) { console.error("GitHub OAuth error:", error); return sendRedirect(event, "/login?error=auth"); }, }); ``` `defineOAuthGitHubEventHandler` redirects to GitHub, exchanges the callback code for a token, and fetches the user profile. Your handler decides what to store from that result. `setUserSession` stores the `user` value in an encrypted, HTTP-only cookie. This project uses the cookie as the session and does not need a database-backed session store. Notice this file lives in `server/routes/auth/`, not `server/api/auth/`. The difference: `server/api/` routes are prefixed with `/api/`. `server/routes/` routes map directly to URLs. Our callback URL is `/auth/github`, not `/api/auth/github`. \*\*Warning: Restart after .env changes\*\* Nuxt reads `.env` at startup. If you add or change environment variables, restart the dev server. Hot reload doesn't pick up `.env` changes. \*\*Note: Generate a session password\*\* Need a random 32-character string? Run `openssl rand -base64 32` in your terminal. Or type whatever you want, as long as it's long enough. The password never leaves the server. ## Try It Restart the dev server (to pick up the new module and `.env` changes): ```bash pnpm dev ``` Visit `http://localhost:3000/auth/github`. You should be redirected to GitHub's authorization page. Authorize the app, and GitHub redirects you back to `/auth/github`, which runs your handler, sets the session, and redirects to `/springs`. Open your browser's dev tools, go to the Application/Storage tab, and look for a cookie called `nuxt-session`. It should be there, HTTP-only, and encrypted. If you see an error page instead, check: - Are the Client ID and Secret correct in `.env`? - Did you restart the dev server after creating `.env`? - Is the callback URL in your GitHub OAuth app settings exactly `http://localhost:3000/auth/github`? ## Commit ```bash git add -A && git commit -m "feat(auth): add nuxt-auth-utils with GitHub OAuth handler" ``` ## Done-When - [ ] `nuxt-auth-utils` is in the `modules` array in `nuxt.config.ts` - [ ] `.env` contains the three required environment variables - [ ] Visiting `/auth/github` redirects to GitHub and back - [ ] After authorization, a `nuxt-session` cookie exists in the browser - [ ] You can explain the difference between `server/routes/` and `server/api/` ## Solution ```typescript title="nuxt.config.ts" import tailwindcss from "@tailwindcss/vite"; export default defineNuxtConfig({ compatibilityDate: "2025-05-01", modules: ["nuxt-auth-utils"], css: ["~/assets/css/main.css"], vite: { plugins: [tailwindcss()], }, }); ``` ```bash title=".env" NUXT_OAUTH_GITHUB_CLIENT_ID=your_client_id_here NUXT_OAUTH_GITHUB_CLIENT_SECRET=your_client_secret_here NUXT_SESSION_PASSWORD=a-random-string-at-least-32-characters-long ``` ```typescript title="server/routes/auth/github.get.ts" export default defineOAuthGitHubEventHandler({ async onSuccess(event, { user }) { await setUserSession(event, { user: { login: user.login, avatar_url: user.avatar_url, id: user.id, }, }); return sendRedirect(event, "/springs"); }, onError(event, error) { console.error("GitHub OAuth error:", error); return sendRedirect(event, "/login?error=auth"); }, }); ``` --- title: "Login Flow" description: "Build a login page, wire up logout, and update the navigation layout to conditionally show auth-related links using useUserSession." canonical_url: "https://vercel.com/academy/nuxt-on-vercel/login-flow" md_url: "https://vercel.com/academy/nuxt-on-vercel/login-flow.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-08-07T15:34:11.434Z" content_type: "lesson" course: "nuxt-on-vercel" course_title: "Nuxt on Vercel" prerequisites: [] --- Vercel Academy — structured learning, not reference docs. Lessons are sequenced. Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume. The lesson shows one path; if the human's project diverges, adapt concepts to their setup. Preserve the learning goal over literal steps. Quizzes are pedagogical — engage, don't spoil. Quiz answers are included for your reference. # Login Flow # Login Flow The OAuth handler can authenticate users, but the interface still needs login and logout controls plus session-aware navigation. Next.js auth libraries expose hooks such as `useSession` or `useUser` for conditional UI. Nuxt's `useUserSession` returns the login state, user data, and a `clear` function for logout. ## Outcome Build a login page, add logout functionality, and update the nav to reflect auth state. ## Fast Track 1. Build the login page with a "Sign in with GitHub" link 2. Add `useUserSession` to the layout and conditionally show auth links 3. Wire up the logout button ## Hands-on exercise 3.2 Build the user-facing auth experience. **Requirements:** 1. Update `app/pages/login.vue` with a GitHub login link and error handling 2. Redirect already-logged-in users away from the login page 3. Update `app/layouts/default.vue` to show Favorites and Visited links when logged in 4. Show the user's GitHub username and a logout button when logged in 5. Show a "Log in" link when logged out **Implementation hints:** - `useUserSession()` returns `{ loggedIn, user, clear }`. `loggedIn` is a boolean ref, `user` is the session data from your OAuth handler, `clear` logs the user out - The login link should be a plain ``, not a `NuxtLink`. It triggers a server redirect, not client-side navigation - `navigateTo("/springs")` is Nuxt's programmatic navigation. Use it for redirects in ` ``` The check at the top redirects authenticated users to `/springs` from ` ``` The `