Course Content
Building AI Features in React
5 sections · 21 lessons
Asking for JSON Your Components Can Render
The first version of the compare feature asked for "a comparison of these two products as a table". The model returned Markdown tables. One run had 3 columns, the next had 9. One cell contained a pipe character, which split it into two columns. Rendering it meant pulling in a Markdown library, and even then the team could not highlight the better product in each row, make the table sortable, or give it proper table headers for screen readers.
A Markdown table is a presentation. A component wants data, which it then presents itself. This lesson designs the compare feature's JSON shape from the CompareTable component's props and writes the prompt that produces it.
Design the JSON from the props
Start with what the component needs to render, not with what the model can produce. Here are the props of the table you will build in section 3:
1// What CompareTable needs to render one row2type Verdict = "a" | "b" | "tie" | "unclear";34type CompareRow = {5 aspect: string; // row header, e.g. "Battery"6 a: string; // what buyers say about product A7 b: string; // what buyers say about product B8 better: Verdict; // drives the highlight style9 evidence: string[]; // review ids that support this row10};Each design choice here has a reason:
- Flat rows. An array of simple objects maps straight onto
<tr>elements. Nesting makes the model more likely to get the structure wrong. - Enums for anything that drives style or logic.
betterpicks a CSS class and an accessible label, so it must be one of four known values. "Product A is somewhat better" in a string cannot drive a style. - Ids, not text, for links.
evidenceholds review ids that the UI turns into links. The UI already has the review text; it only needs to know which ones. - An explicit "unclear". Without it, the model must pick a winner even when reviews disagree. The option gives it an honest answer to choose.
- Nothing that code already knows. Product names, prices and average stars come from your database. Asking the model for them costs tokens and gives it a chance to get them wrong.
Put the shape in the prompt
The prompt shows one complete example object and states each rule in plain words. Models follow a concrete example far more reliably than a description alone.
1// server/prompts.ts (continued)2export const COMPARE_PROMPT_VERSION = "compare-v2";34export const COMPARE_SYSTEM = `You compare two products using only their buyer reviews.5Reply with a single JSON object and nothing else, in exactly this shape:6{"rows":[{"aspect":"Battery","a":"...","b":"...","better":"a","evidence":["r1042","r2217"]}]}7Rules:8- 3 to 6 rows. Each aspect is 1 to 3 words, such as "Battery" or "Comfort".9- "a" describes product A and "b" describes product B, each under 90 characters.10- "better" is one of "a", "b", "tie" or "unclear". Use "unclear" when reviews disagree or are few.11- "evidence" lists 1 to 4 review ids that support the row, taken only from the input.12- If aspects are given, use exactly those aspects, in that order.`;1314export function compareUserPrompt(15 a: { name: string; reviews: Review[] },16 b: { name: string; reviews: Review[] },17 aspects?: string[],18): string {19 const wanted = aspects?.length ? `Aspects: ${aspects.join(", ")}\n\n` : "";20 return `${wanted}Product A: ${a.name}\n${formatReviews(a.reviews)}\n\n` +21 `Product B: ${b.name}\n${formatReviews(b.reviews)}`;22}Notice the 90-character rule. At 360 pixels wide, each product column is about 140 pixels, which fits about 22 characters per line. A 90-character cell is four lines, the most the row can hold before it looks broken. That number came from the layout, not from a guess.
Let the provider constrain the output, when it can
Most major providers now offer a structured-output mode. You pass a JSON Schema, and the model is constrained during generation so that its reply parses and matches the schema's structure. This removes most shape failures: no code fences, no missing braces, no extra prose.
It is worth using when your provider supports it, but it does not remove validation, for three reasons:
- Meaning is not checked. A schema can say
evidenceis an array of strings. It cannot say those strings must be ids from this request. - Not every rule is supported. Some providers ignore or reject schema keywords like maximum string length or array length.
- Replies can still end early. A token limit, a refusal or a network error can leave you with something that is not the object you wanted.
ShopLens keeps its llm wrapper text-in, text-out so it works with any provider, and puts the shape in the prompt. The Zod check in the next lessons catches the rest. If you switch on a provider's structured mode later, the validation code does not change; it just fails less often.
Small shapes are faster shapes
Every output token takes time to generate. In the compare shape, one row is about 60 tokens, so six rows is about 360 tokens. At 60 tokens per second, that is 6 seconds of generation. Twelve rows would be 12 seconds, and a shopper will not wait that long for a table.
This is why the prompt caps rows at six and cells at 90 characters. It is also why the JSON uses short, readable keys like a and b rather than productADescriptionFromReviews: every key is repeated in every row and costs tokens each time. Do not go to the other extreme with keys like x1; the model follows meaningful keys more reliably, and your teammates can read the logs.
Check your understanding
0 of 3 answered
1.Why is better an enum of "a", "b", "tie" and "unclear" rather than a free-text string?
2.You turn on your provider's structured-output mode with a JSON Schema. What validation do you still need?
3.The compare prompt asks the model to include each product's name and price in the JSON. What is wrong with that?