Building AI Features in React

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 plumbing every feature importsclient.ts — typed fetch and errorsapp.ts — one route per featurellm.ts — the only provider codeschemas.ts — one shared contract
Because postJson demands a schema, forgetting validation is a compile error rather than an incident.

The project layout

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

TypeScript
// server/llm.ts (part 1)import Anthropic from "@anthropic-ai/sdk";export type ModelTier = "fast" | "main";export type LlmRequest = {  tier: ModelTier;  system: string;  user: string;  maxTokens: number;  signal?: AbortSignal;};export interface Llm {  complete(req: LlmRequest): Promise<string>;  stream(req: LlmRequest): AsyncIterable<string>;}// Model ids are configuration. Check your provider's current model list.const MODELS = {  fast: { id: process.env.SHOPLENS_FAST_MODEL ?? "claude-haiku-4-5" },  main: { id: process.env.SHOPLENS_MAIN_MODEL ?? "claude-opus-5", effort: "low" as const },};const client = new Anthropic(); // reads ANTHROPIC_API_KEY from the server environmentfunction params(req: LlmRequest) {  const model = MODELS[req.tier];  return {    model: model.id,    max_tokens: req.maxTokens,    system: req.system,    messages: [{ role: "user" as const, content: req.user }],    ...("effort" in model ? { output_config: { effort: model.effort } } : {}),  };}
TypeScript
// server/llm.ts (part 2)export const llm: Llm = {  async complete(req) {    const message = await client.messages.create(params(req), { signal: req.signal });    return message.content      .map((block) => (block.type === "text" ? block.text : ""))      .join("");  },  async *stream(req) {    const stream = client.messages.stream(params(req), { signal: req.signal });    for await (const event of stream) {      if (event.type === "content_block_delta" && event.delta.type === "text_delta") {        yield event.delta.text;      }    }  },};

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.

TypeScript
// server/app.tsimport express, { type NextFunction, type Request, type Response } from "express";import { compareRoute } from "./routes/compare";import { sentimentRoute } from "./routes/sentiment";import { summaryRoute } from "./routes/summary";const app = express();app.use(express.json({ limit: "10kb" })); // requests carry ids, never review textapp.post("/api/sentiment", sentimentRoute);app.post("/api/summary", summaryRoute);app.post("/api/compare", compareRoute);// Express 5 sends errors thrown in async handlers here. With Express 4, add try/catch in each route.app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {  console.error(err);  if (!res.headersSent) res.status(502).json({ error: "This feature is unavailable right now" });});app.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.

TypeScript
// src/api/client.ts (part 1)import type { ZodType } from "zod";export class ApiError extends Error {  readonly status: number; // 0 means there was no HTTP response at all  readonly retryAfterMs: number | undefined;  constructor(message: string, status: number, retryAfterMs?: number) {    super(message);    this.name = "ApiError";    this.status = status;    this.retryAfterMs = retryAfterMs;  }  get retryable(): boolean {    return this.status === 0 || this.status === 429 || this.status >= 500;  }}async function send(path: string, body: unknown, signal?: AbortSignal): Promise<Response> {  let res: Response;  try {    res = await fetch(path, {      method: "POST",      headers: { "Content-Type": "application/json" },      body: JSON.stringify(body),      signal,    });  } catch (err) {    if (signal?.aborted) throw err; // callers recognise their own abort    throw new ApiError("Network error", 0);  }  if (!res.ok) {    const data = (await res.json().catch(() => null)) as { error?: string } | null;    const seconds = Number(res.headers.get("Retry-After"));    throw new ApiError(data?.error ?? res.statusText, res.status, seconds > 0 ? seconds * 1000 : undefined);  }  return res;}
TypeScript
// src/api/client.ts (part 2)export async function postJson<T>(  path: string, body: unknown, schema: ZodType<T>, signal?: AbortSignal,): Promise<T> {  const res = await send(path, body, signal);  let data: unknown;  try {    data = await res.json();  } catch (err) {    if (signal?.aborted) throw err;    throw new ApiError("The response was not JSON", 502);  }  const parsed = schema.safeParse(data);  if (!parsed.success) throw new ApiError("The response had an unexpected shape", 502);  return parsed.data;}export async function streamText(  path: string, body: unknown, onText: (text: string) => void, signal: AbortSignal,): Promise<void> {  const res = await send(path, body, signal);  if (!res.body) throw new ApiError("The response had no body", 502);  const reader = res.body.getReader();  const decoder = new TextDecoder();  try {    for (;;) {      const { done, value } = await reader.read();      if (done) break;      const text = decoder.decode(value, { stream: true });      if (text) onText(text);    }  } catch (err) {    if (signal.aborted) throw err;    throw new ApiError("The stream was interrupted", 0);  }  const rest = decoder.decode();  if (rest) onText(rest);}

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?