Course Content
Building AI Features in React
5 sections · 21 lessons
Setup: The BFF Route and a Typed Client
Every ShopLens feature needs the same three pieces of plumbing: a server-side client that talks to the model, a server that exposes one route per feature, and a browser-side client that calls those routes and checks what comes back. If each feature builds its own plumbing, you get three slightly different ways of handling errors, timeouts and cancellation, and three places to fix every bug.
This lesson builds the plumbing once. It is the least exciting code in the course and the most reused: every later lesson imports from these files. The code uses Node 20 or later, Express 5, React 18, TypeScript 5 and Zod.
The project layout
shoplens/ shared/ schemas.ts Zod schemas and types, used by both sides server/ llm.ts the only file that knows which model provider you use prompts.ts system prompts and prompt builders parseModelJson.ts safe JSON parsing (section 2) completeJson.ts call, validate, repair once (section 2) data.ts products and reviews from your database routes/ sentiment.ts summary.ts compare.ts app.ts src/ api/client.ts postJson, streamText, ApiError hooks/ useSentiment, useStreamingSummary, useCompare state/ requestMachine.ts (lesson 5 of this section) components/ SentimentBadge, BuyerSummary, CompareTable, ...The shared folder is the contract between the browser and the server. Both sides import the same Zod schemas, so a change to a shape is a change in one place, and TypeScript shows every call site that needs updating.
A small llm client you own
The rest of ShopLens never imports a provider SDK. It imports llm from server/llm.ts, which exposes two methods: complete, which returns the whole reply as a string, and stream, which yields text pieces as they arrive. This example uses Anthropic's TypeScript SDK; an adapter for another provider has the same shape.
1// server/llm.ts (part 1)2import Anthropic from "@anthropic-ai/sdk";34export type ModelTier = "fast" | "main";56export type LlmRequest = {7 tier: ModelTier;8 system: string;9 user: string;10 maxTokens: number;11 signal?: AbortSignal;12};1314export interface Llm {15 complete(req: LlmRequest): Promise<string>;16 stream(req: LlmRequest): AsyncIterable<string>;17}1819// Model ids are configuration. Check your provider's current model list.20const MODELS = {21 fast: { id: process.env.SHOPLENS_FAST_MODEL ?? "claude-haiku-4-5" },22 main: { id: process.env.SHOPLENS_MAIN_MODEL ?? "claude-opus-5", effort: "low" as const },23};2425const client = new Anthropic(); // reads ANTHROPIC_API_KEY from the server environment2627function params(req: LlmRequest) {28 const model = MODELS[req.tier];29 return {30 model: model.id,31 max_tokens: req.maxTokens,32 system: req.system,33 messages: [{ role: "user" as const, content: req.user }],34 ...("effort" in model ? { output_config: { effort: model.effort } } : {}),35 };36}1// server/llm.ts (part 2)2export const llm: Llm = {3 async complete(req) {4 const message = await client.messages.create(params(req), { signal: req.signal });5 return message.content6 .map((block) => (block.type === "text" ? block.text : ""))7 .join("");8 },910 async *stream(req) {11 const stream = client.messages.stream(params(req), { signal: req.signal });12 for await (const event of stream) {13 if (event.type === "content_block_delta" && event.delta.type === "text_delta") {14 yield event.delta.text;15 }16 }17 },18};A few details matter here. The two tiers match the course's cost model: the small model for the badge, the larger one for the summary and the table. The main tier sets a low effort, which tells a model that can reason before answering to spend little time doing so. For a 60-word summary, that shortens the time to the first visible word, which is what shoppers feel. The signal passes straight through to the SDK, so aborting it closes the connection to the provider and stops generation.
Because the rest of the app sees only the Llm interface, you can swap providers by rewriting this one file, and in section 5 you will replace it with a fake that streams recorded text in tests. In production you would also log each reply's token usage and stop reason here; they are how you notice a prompt that suddenly doubled in cost or keeps hitting its token limit.
One default deserves attention. Provider SDKs are built for long jobs, so their default timeouts are generous: Anthropic's TypeScript SDK waits up to 10 minutes per request and retries some failures twice. That is right for a batch job and wrong for a product page, where nobody waits 30 seconds for a badge. So every ShopLens route passes its own signal with a timeout that matches the feature: 10 seconds for the badge, 20 for the table, and for the summary, "until the shopper leaves". The SDK's automatic retries on rate limits and server errors are useful, but remember that each retry adds to the wait the shopper feels.
The Express app
The server is short, because the routes do the work. Two lines deserve attention: the body size limit and the error handler.
1// server/app.ts2import express, { type NextFunction, type Request, type Response } from "express";3import { compareRoute } from "./routes/compare";4import { sentimentRoute } from "./routes/sentiment";5import { summaryRoute } from "./routes/summary";67const app = express();8app.use(express.json({ limit: "10kb" })); // requests carry ids, never review text910app.post("/api/sentiment", sentimentRoute);11app.post("/api/summary", summaryRoute);12app.post("/api/compare", compareRoute);1314// Express 5 sends errors thrown in async handlers here. With Express 4, add try/catch in each route.15app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {16 console.error(err);17 if (!res.headersSent) res.status(502).json({ error: "This feature is unavailable right now" });18});1920app.listen(3001, () => console.log("ShopLens BFF on http://localhost:3001"));The 10 KB limit is a quiet security control. ShopLens requests carry a product id and at most 20 review ids, which is well under 1 KB. A client that tries to send 2 MB of text is not a ShopLens client, and it is rejected before any code runs. In development, the Vite dev server forwards /api to this port with server: { proxy: { "/api": "http://localhost:3001" } } in vite.config.ts, so the browser code uses relative paths in every environment.
The routes read data through three small functions in server/data.ts: getProduct(id), getTopReviews(productId, limit) sorted by helpful votes, and getReviewsByIds(productId, ids). Implement them against your own database. The only new shared type is one line in shared/schemas.ts: export type Product = { id: string; name: string };.
The typed browser client
The browser side has one job: turn HTTP into typed values or typed errors. Nothing else in the React code calls fetch directly.
1// src/api/client.ts (part 1)2import type { ZodType } from "zod";34export class ApiError extends Error {5 readonly status: number; // 0 means there was no HTTP response at all6 readonly retryAfterMs: number | undefined;78 constructor(message: string, status: number, retryAfterMs?: number) {9 super(message);10 this.name = "ApiError";11 this.status = status;12 this.retryAfterMs = retryAfterMs;13 }1415 get retryable(): boolean {16 return this.status === 0 || this.status === 429 || this.status >= 500;17 }18}1920async function send(path: string, body: unknown, signal?: AbortSignal): Promise<Response> {21 let res: Response;22 try {23 res = await fetch(path, {24 method: "POST",25 headers: { "Content-Type": "application/json" },26 body: JSON.stringify(body),27 signal,28 });29 } catch (err) {30 if (signal?.aborted) throw err; // callers recognise their own abort31 throw new ApiError("Network error", 0);32 }33 if (!res.ok) {34 const data = (await res.json().catch(() => null)) as { error?: string } | null;35 const seconds = Number(res.headers.get("Retry-After"));36 throw new ApiError(data?.error ?? res.statusText, res.status, seconds > 0 ? seconds * 1000 : undefined);37 }38 return res;39}1// src/api/client.ts (part 2)2export async function postJson<T>(3 path: string, body: unknown, schema: ZodType<T>, signal?: AbortSignal,4): Promise<T> {5 const res = await send(path, body, signal);6 let data: unknown;7 try {8 data = await res.json();9 } catch (err) {10 if (signal?.aborted) throw err;11 throw new ApiError("The response was not JSON", 502);12 }13 const parsed = schema.safeParse(data);14 if (!parsed.success) throw new ApiError("The response had an unexpected shape", 502);15 return parsed.data;16}1718export async function streamText(19 path: string, body: unknown, onText: (text: string) => void, signal: AbortSignal,20): Promise<void> {21 const res = await send(path, body, signal);22 if (!res.body) throw new ApiError("The response had no body", 502);23 const reader = res.body.getReader();24 const decoder = new TextDecoder();25 try {26 for (;;) {27 const { done, value } = await reader.read();28 if (done) break;29 const text = decoder.decode(value, { stream: true });30 if (text) onText(text);31 }32 } catch (err) {33 if (signal.aborted) throw err;34 throw new ApiError("The stream was interrupted", 0);35 }36 const rest = decoder.decode();37 if (rest) onText(rest);38}Three decisions shape this client. First, every failure becomes an ApiError except an abort. An abort is not a failure; it is the user or the component saying "I no longer want this", so the original error passes through and callers check signal.aborted. Second, retryable is computed in one place: network errors, rate limits and server errors may succeed later, while a 400 or a 422 never will. Third, postJson cannot return unvalidated data, because it requires a schema. The rule from section 2, "nothing enters state without passing a schema", is now enforced by a function signature instead of by memory.
Check your understanding
0 of 3 answered
1.Why does postJson take a Zod schema as a required argument?
2.In send, why is an aborted request re-thrown as-is instead of wrapped in an ApiError?
3.The main model tier sets a low effort. What does that improve for the summary?