πͺ Return a typed comparison
EpicStore now has a comparison list. Click Add to compare on any product card and that product joins the list. The list lives in
localStorage (epicstore.compare.v1) and a badge in the header tracks how many products are in it. Click the badge β or any "View comparison" link β and you land on /compare.That page is half-wired. The list is loaded from storage, the products being compared render as cards, related products show up as "Similar products to compare against," and the chat panel is mounted at the bottom. Try it: add 2β3 products to the list, open
/compare, ask the chat "what's the best value?"The spinner spins. The request goes out. Andβ¦ nothing useful comes back. The model is replying with prose β but the page is built to render a typed comparison object, so nothing is shown.
Your job is to constrain the model's reply to a Zod schema so it returns the typed shape the table needs.
π¨ What you will do
Three files, each annotated with π¨ Kody and π° Marty comments that walk you through the change right where you make it β there's nothing to flip back here for. Open them in order:
app/schemas/comparison.tsβ uncomment Marty's pre-written descriptions and constraints so the model knows what to put in each field.app/routes/api.compare.tsxβ wire the schema into thechat()call.app/components/comparison-chat.tsxβ render the typed result.
The sections below are background on why this works β read them if you're curious, but the steps live in the files.
π¦ How outputSchema reaches the model
TanStack AI walks your Zod schema, converts it to JSON Schema, and ships it to the provider's structured-output API. The provider hands the JSON Schema to the model as part of the prompt, and the model is constrained at generation time to emit JSON that conforms.
That's why descriptions matter so much β they're literally part of the prompt the model reads. Tight, specific descriptions ("a plain integer in US cents") produce reliable output; vague ones ("the price") leave the model to guess units, formatting, and intent.
The
.min(2) you'll add to dimensions becomes a JSON Schema "minItems": 2. The model sees that and stops trying to return a single-row comparison. The .nullable() you'll add to edge becomes "type": ["string", "null"], which lets the model say "this dimension is a tie" without guessing a productId.π¦ About the compare list
The compare list is a plain
Array<string> of product IDs stored in localStorage (epicstore.compare.v1). The <CompareProvider> wraps the app and exposes useCompare() to any component β that's how the ProductCard button toggles a product in and out of the list, and how the header badge knows the count. The /compare page just reads compare.ids after hydration and fetches the product data from /api/compare-data. None of that is something you author in this exercise β it's there so you have something real to compare.π Docs
- π TanStack AI β structured outputs
- π TanStack AI β streaming structured output
- π Zod
.describe()
β How to know it is working
- Visit
/products. Click Add to compare on two or more product cards. The card buttons fill in amber and the header badge counts up. - Click the Compare badge in the header (the icon next to the cart). You land on
/comparewith your products listed and a row of suggestions below labelled Similar products to compare against. - In the chat panel, type a question like "What's the best for everyday use?" and hit send.
- After 15β30 seconds, the spinner is replaced with a comparison table: a ranking list, a side-by-side dimensions table, and a one-line verdict.
- Send a follow-up like "Rank by price-to-quality ratio." You get a fresh comparison back.
- Click Add to compare on one of the suggestions. The next comparison includes that product. Click the Γ on any card in the In your comparison section to remove it.
While the model is thinking, you see the spinner. The whole table appears at once when validation completes β that's the
final field doing its job.π¨ If you get stuck
- The spinner never goes away and you never see a table β check that
chat({ ... })has bothoutputSchemaandstream: true. Withoutstream: true, the route can still return a response, butuseChatwon't receive structured frames andfinalstaysnull. - TypeScript complains that
finaldoes not exist on theuseChatreturn β you haven't addedoutputSchematouseChatyet. The hook's return type changes whenoutputSchemais set. - You see a
400or a runtime validation error β your schema requires fields the model isn't providing. Look at the validation error to see which field is missing or wrong-shaped, then check the description on that field β is it specific enough to tell the model what to do? - The table renders but
edgeshows the wrong product (or "β" everywhere) β make sureedgeis.nullable(), otherwise the model is forced to pick a winner on every dimension and may guess at random when there's no clear edge. - The model returns a comparison with only 1 dimension β add
.min(2)to thedimensionsarray. The model treats minimums as hard requirements. - The compare list is empty on
/compareand you swear you added items β the list is read from localStorage after hydration. Open devtools β Application β Local Storage and look forepicstore.compare.v1. If it's missing, the<CompareProvider>isn't in scope β but it should be, it's wired inapp/root.tsx.