If you have ever tried to bring a Canvas- or Artifacts-like experience into your own product and stalled at the very first decision, you are in good company. The first time I attempted this, I asked Claude to return Markdown, parsed it on the client, and shipped. The result was a stuttering UI and an HTML escaping bug that came one CVE away from being a real incident. We rebuilt it from scratch.
What you actually need to ship generative UI is not a clever prompt that makes Claude "draw better." It is an architecture that makes whatever Claude draws fast, safe, and predictable to render. This article walks through the production pattern I use today: Anthropic SDK Tool Use, a Zod-backed component registry, and partial JSON streaming, with all the production caveats baked in.
What Generative UI actually changes about UX
The shortest definition of generative UI is "the layout and contents of the screen are produced by the LLM in real time." When a user asks "Show me last week's sales by region," it is Claude that decides whether to return only a table, or a chart plus a table plus a few suggested follow-up actions. The layout itself becomes part of the answer.
The crucial constraint is that Claude does not return raw HTML. In a production-grade implementation, you give Claude a fixed set of allowed components (a "component registry") and a strict JSON Schema for each component's props. Claude can compose only within those constraints.
This sounds limiting, but in practice the quality goes up, not down. If you only take one piece of advice from this article, take this one: start with five to seven allowed components, not twenty. Once you cross ten, Claude's planning quality drops in a way that is easy to feel even before it shows in your metrics.
Three implementation styles, and which one I recommend
Generative UI implementations roughly fall into three families. They look similar on a slide deck but feel very different in production, and the wrong starting choice makes everything downstream painful.
The first family asks Claude to return text or Markdown and parses it into UI on your side. It ships fast and feels free, but any wobble in the response breaks rendering instantly. I only use this for throwaway prototypes.
The second family uses structured output: you tell Claude to return JSON that satisfies a JSON Schema. The shape is reliable, but it composes poorly with tool execution and partial streaming. The user experience is "blank screen for two seconds, then everything appears at once."
The third family — the one I will focus on — uses Tool Use. You define a render_component tool, Claude calls it as many times as it needs, and you render each call. Tool Use is the only path where the Anthropic SDK ships first-class support for partial input streaming via input_json_delta, which is what lets us draw the UI as Claude assembles it.
If you want to deepen each side independently, the structured output mastery guide goes into detail on JSON Schema modeling, and the streaming Tool Use deep-dive covers everything we will only touch on here. Reading both fills in adjacent gaps you will hit later.
Architecture: separate rendering into three honest layers
The architecture I run in production splits rendering into three layers on purpose. If those boundaries blur, types loosen, and when something breaks you cannot tell which side broke it.
The first layer is the component registry. Each entry pairs an ID with a React implementation and a Zod schema for its props. The second layer is the LLM I/O layer. It auto-generates an Anthropic tool definition from the registry, sends it to Claude, and consumes the streaming tool_use content blocks. The third layer is the render layer, which incrementally parses partial JSON, validates final props with Zod, and renders the matching React component.
The single biggest reason to do this is that adding a new component should require touching only the registry. Hand-writing tool definitions guarantees a typo in a prop name, and that typo shows up at the worst possible moment.
Implementation 1: a registry that is the single source of truth
Let's wire up the registry first. We will derive the JSON Schema for the tool definition directly from Zod, so adding a component never means hand-writing schema twice.
// src/genui/registry.ts
import { z } from "zod";
import zodToJsonSchema from "zod-to-json-schema";
import type { ReactNode } from "react";
import { Heading } from "@/components/genui/Heading";
import { BarChart } from "@/components/genui/BarChart";
import { DataTable } from "@/components/genui/DataTable";
import { ActionButton } from "@/components/genui/ActionButton";
import { Callout } from "@/components/genui/Callout";
// One row per allowed component. Schema, description, and renderer co-live.
export const componentRegistry = {
heading: {
description: "Section heading rendered as h2.",
schema: z.object({
text: z.string().min(1).max(120),
level: z.union([z.literal(2), z.literal(3)]).default(2),
}),
render: (props: { text: string; level?: 2 | 3 }): ReactNode => (
<Heading {...props} />
),
},
bar_chart: {
description: "Bar chart for numeric data. Up to 24 x values.",
schema: z.object({
title: z.string().max(80),
x: z.array(z.string()).min(1).max(24),
y: z.array(z.number()).min(1).max(24),
}),
render: (props: { title: string; x: string[]; y: number[] }) => (
<BarChart {...props} />
),
},
data_table: {
description: "Simple table. Up to 6 columns and 100 rows.",
schema: z.object({
columns: z.array(z.string()).min(1).max(6),
rows: z.array(z.array(z.union([z.string(), z.number()]))).max(100),
}),
render: (props: any) => <DataTable {...props} />,
},
action_button: {
description: "A user-facing next action. Maximum three per response.",
schema: z.object({
label: z.string().min(1).max(40),
action_id: z.string().regex(/^[a-z][a-z0-9_]{1,40}$/),
}),
render: (props: { label: string; action_id: string }) => (
<ActionButton {...props} />
),
},
callout: {
description: "Highlighted note or warning.",
schema: z.object({
tone: z.enum(["info", "warn", "success"]),
body: z.string().max(240),
}),
render: (props: any) => <Callout {...props} />,
},
} as const;
export type ComponentId = keyof typeof componentRegistry;
// Build the Anthropic tool definition automatically from the registry.
export function buildRenderTool() {
const blockSchemas = Object.entries(componentRegistry).map(([id, def]) => ({
type: "object" as const,
required: ["component", "props"],
properties: {
component: { const: id },
props: zodToJsonSchema(def.schema, { target: "openApi3" }),
},
}));
return {
name: "render_component",
description:
"Render exactly one UI block for the user. Use only allowed components.",
input_schema: {
type: "object",
required: ["block"],
properties: { block: { oneOf: blockSchemas } },
},
} as const;
}The reason the tool's input_schema is generated from Zod, not authored by hand, is that every time I have hand-maintained two parallel definitions, a prop drifted. Driving everything from the registry means new components instantly become visible to Claude, and removed ones disappear from the schema.
The "allowed list" approach is also defensive. Without it, Claude will happily emit an <iframe> or invent props that do not exist. With JSON Schema enforcement at the tool layer, those slip-ups never reach your renderer.
Implementation 2: streaming partial JSON safely
To remove the "waiting" feeling that wrecks generative UI demos, you have to consume Tool Use streaming correctly. The Anthropic SDK sends input_json_delta events that incrementally deliver each tool call's input as a string. We can parse it incrementally with a best-effort JSON parser and re-validate at the end.
// src/genui/stream.ts
import Anthropic from "@anthropic-ai/sdk";
import { parse as partialParse } from "best-effort-json-parser";
import { componentRegistry, buildRenderTool, type ComponentId } from "./registry";
export type GenUIBlock = {
index: number;
component: ComponentId | null;
props: unknown;
status: "streaming" | "ready" | "invalid";
error?: string;
};
const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY! });
export async function* streamGenerativeUI(userQuery: string): AsyncGenerator<GenUIBlock> {
const tool = buildRenderTool();
const blocks: Map<number, GenUIBlock> = new Map();
// A 30s timeout via AbortController is non-negotiable in production.
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 30_000);
try {
const stream = client.messages.stream(
{
model: "claude-sonnet-4-6",
max_tokens: 4096,
tools: [tool],
tool_choice: { type: "any" },
system:
"You are a UI renderer. Read the user's question, then call render_component as many times as needed. The order of calls is the display order.",
messages: [{ role: "user", content: userQuery }],
},
{ signal: controller.signal },
);
const buffers: Map<number, string> = new Map();
for await (const event of stream) {
if (
event.type === "content_block_start" &&
event.content_block.type === "tool_use"
) {
buffers.set(event.index, "");
const block: GenUIBlock = {
index: event.index,
component: null,
props: null,
status: "streaming",
};
blocks.set(event.index, block);
yield block;
} else if (
event.type === "content_block_delta" &&
event.delta.type === "input_json_delta"
) {
const prev = buffers.get(event.index) ?? "";
const next = prev + event.delta.partial_json;
buffers.set(event.index, next);
// Best-effort partial parse so we can paint while Claude is still typing.
try {
const parsed = partialParse(next);
const blockData = parsed?.block;
const current = blocks.get(event.index);
if (current && blockData?.component) {
current.component = blockData.component as ComponentId;
current.props = blockData.props ?? {};
yield current;
}
} catch {
// Ignore — wait for the next delta.
}
} else if (event.type === "content_block_stop") {
const finalText = buffers.get(event.index);
if (!finalText) continue;
const current = blocks.get(event.index);
if (!current) continue;
try {
const finalJson = JSON.parse(finalText);
const block = finalJson.block;
const def = componentRegistry[block.component as ComponentId];
if (!def) {
current.status = "invalid";
current.error = `unknown component: ${block.component}`;
yield current;
continue;
}
// ⭐ The critical bit — Zod validates props one final time.
const result = def.schema.safeParse(block.props);
if (!result.success) {
current.status = "invalid";
current.error = result.error.issues
.map((i) => `${i.path.join(".")}:${i.message}`)
.join(", ");
yield current;
continue;
}
current.component = block.component as ComponentId;
current.props = result.data;
current.status = "ready";
yield current;
} catch (err) {
current.status = "invalid";
current.error = err instanceof Error ? err.message : String(err);
yield current;
}
}
}
} finally {
clearTimeout(timer);
}
}The shape that matters here is "render permissively while streaming, validate strictly at completion." Partial parsing only promises "something that looks JSON-ish so far," so a chart can briefly hold y: [1, 2, "abc"] mid-flight. The final Zod pass guarantees the UI only ever sees streaming → ready or streaming → invalid. There is no in-between to leak through.
In practice, when Claude calls render_component five times you should expect roughly five blocks × eight to fifteen partial yields each, which translates to a UI where each block fills in smoothly instead of popping all at once.
Implementation 3: stateful partial updates from user actions
The line between "demo-grade generative UI" and "actually useful business UI" is whether you can update one block in response to a user click without re-running the whole layout. That is what action_button is for.
// src/genui/server.ts
import { streamGenerativeUI } from "./stream";
type SessionState = {
history: { role: "user" | "assistant"; content: string }[];
blocks: Awaited<ReturnType<typeof toArray>>;
};
const sessions = new Map<string, SessionState>();
async function toArray<T>(gen: AsyncIterable<T>) {
const out: T[] = [];
for await (const v of gen) out.push(v);
return out;
}
export async function handleUserMessage(sessionId: string, query: string) {
const session = sessions.get(sessionId) ?? { history: [], blocks: [] };
session.history.push({ role: "user", content: query });
// Cap at the last 8 turns to bound both context and cost.
const recent = session.history.slice(-8);
const blocks = await toArray(streamGenerativeUI(recent.map((m) => m.content).join("\n\n")));
session.blocks = blocks.filter((b) => b.status === "ready");
sessions.set(sessionId, session);
return session.blocks;
}
export async function handleAction(sessionId: string, actionId: string) {
const session = sessions.get(sessionId);
if (!session) throw new Error("session_not_found");
const prompt = `The user chose action "${actionId}". Re-render only the single most relevant block from the previous response.`;
const blocks = await toArray(streamGenerativeUI(prompt));
// ⭐ Replace the matching existing block in place; keep everything else.
const replaced = session.blocks.map((b) => {
const swap = blocks.find((nb) => nb.component === b.component && nb.status === "ready");
return swap ?? b;
});
session.blocks = replaced;
return session.blocks;
}The point here is "do not regenerate the whole screen." Full regeneration is easier to write, but it slows the perceived response and costs you four to five times more in tokens. After we moved to per-block replacement on follow-up actions, our monthly Claude API spend dropped to roughly 30% of its peak, with no measurable change in perceived quality.
Production design: caching, observability, and fallback
There are four production concerns that always show up the moment generative UI escapes the demo. Build each of them into the very first deploy and you avoid almost every "we should have done that earlier" conversation.
The first is prompt caching. Your renderer's system prompt and tool definition do not change per request, so flagging them with cache_control saves obvious latency on subsequent turns within a session. We cache the tool definition specifically because it grows quickly as you add components. The Prompt Caching guide has the details — typical wins are 30–50% on repeat calls.
The second is fallback for invalid blocks. If Zod rejects a block at the final validation step, deleting it silently breaks UX. Our policy is to substitute a fixed callout with tone: warn and a short message. Showing something honest is far better than showing nothing.
The third is observability. You do not need a fancy stack — just emit four counters per request: number of blocks, number of tool calls, ratio of invalid to total, and average stream duration. Once invalid crosses 5%, that is your sign that the system prompt or schema confused Claude, not that the API is misbehaving.
The fourth is a circuit breaker. With tool_choice: any, a confused Claude can produce ten invalid blocks in a single response. After three consecutive failed sessions, fall back to a static "safe" template UI. It is the cheapest insurance you will ever buy.
Why this architecture beats "just return JSX"
It is tempting to ask Claude to return a JSX or HTML string and skip the whole registry layer. I tried it. Three weeks later we ripped it out. Here is what actually happens.
When you let the model author markup directly, you inherit two problems at once. The first is security — any user-facing prompt can suddenly contain <script> injections, attribute-based XSS, or unbounded <img> tags pointing to tracking pixels. Sanitizing JSX with a denylist works on day one and breaks on day fifteen, because Claude (correctly) keeps inventing new patterns. The second is debuggability — when a layout looks "off," you have to diff free-form markup across runs, which means you have nothing concrete to alert on, regression-test against, or measure.
Constraining output to a tool call with a JSON Schema flips both of those. Security becomes an allowlist problem instead of a denylist problem, which is the only kind of security problem you can actually win. Debuggability becomes a structured-data problem: you can log every block, replay any session, snapshot UIs in tests, and even diff two Claude responses with a normal JSON differ. The price you pay is having to maintain a registry, and that price is dramatically lower than the price of either of the two problems above.
There is one more reason I like the registry approach: it gives your design team back control of the surface. Designers tend to be — rightly — uncomfortable with "the AI gets to draw whatever it wants." When the registry is the source of truth, every component in your generative UI has the same Figma reference, the same accessibility audit, and the same dark-mode story as the rest of the product. The "AI part" becomes the orchestration, not the visual language.
Testing and evaluation: how I keep the renderer honest
Generative UI tests are different from normal UI tests, and skipping them is the most common reason teams ship something fragile. The deceptive bit is that "the API responded 200" is not a useful signal here — the bar is "Claude assembled an appropriate UI for this query."
We run three layers of tests. The first is schema-conformance tests, which are the cheapest. We replay roughly 50 representative user queries against the renderer, parse every emitted block, and assert that 100% pass Zod validation. Anything below 100% is a regression. We run this on every PR; it takes about 90 seconds.
The second is layout reasonableness tests. For each replayed query, we assert lightweight structural properties: a query about trends always emits at least one chart, a query about lists always emits a table, and we never emit more than two action_buttons in a single response. These tests are written as plain TypeScript — no special framework — and they catch most "Claude got confused by a system prompt change" regressions before they reach staging.
The third is eval-style judgment tests, which are the slowest and most useful. We send the rendered block list back to Claude with a separate "judge" prompt that scores helpfulness on a 1–5 scale and explains its reasoning. We average across queries and alert when the score drops by more than 0.3 from the previous baseline. We run this nightly, not on every commit, and it has caught two prompt-engineering regressions that schema tests missed entirely.
A small but valuable detail: store every test response with its model version (claude-sonnet-4-6, etc.) and your prompt version. When Anthropic ships a new model, you want to be able to say "the regression is from the model upgrade, not from our code" with evidence in hand. Without that snapshot, you will re-litigate the same question every quarter.
Picking the right model for the renderer
Choosing which Claude model to power generative UI is not the same decision as picking the model for a chatbot. The renderer's job is mechanical — pick allowed components, fill props correctly — and big jumps in raw reasoning ability matter less than reliability and latency. After running the same workload across the model lineup, here is the practical decision tree we use today.
For interactive UI with real users in the loop, Claude Sonnet 4.6 is the default. It hits the right point on the latency-vs-quality curve: tool calls return in under a second for typical block counts, the partial JSON it streams is well-formed enough that best-effort parsing rarely fails mid-flight, and its planning is good enough that we have not seen it produce more than one off-target component per fifty user sessions. The "off-target" cases are almost always recoverable through the schema fallback path, which is exactly what that path is for.
For backend workflows where a generative UI is rendered into an email digest or a report — i.e., latency does not matter — Claude Opus 4.6 is worth the upgrade. The difference shows up in two places: it picks the right component on edge-case queries that Sonnet sometimes mis-routes, and the prose it puts inside callout and heading blocks reads noticeably better. We pay for it on the read-once paths and stay on Sonnet everywhere else.
We have not yet found a use case where dropping to Haiku is the right call for the orchestrator. Haiku is excellent for downstream sub-agents that the orchestrator may invoke — say, summarizing a row of data — but as the renderer itself, it tends to skip allowed components and call tool_choice less reliably. If you really want to save money on the renderer, the bigger lever is prompt caching plus tool-definition reuse, not switching models.
Cost guardrails that actually held
The single most useful production-cost number I can share is this: when we first launched generative UI internally, our average cost per resolved user query was roughly $0.012. Three months and three optimizations later, it is $0.0034. That is a 65% reduction with zero perceived quality loss, and the levers are not exotic.
The first lever was prompt caching the tool definition. Once you have ten or more components in the registry, the JSON Schema becomes the largest static piece of every request. Caching it cut our prompt token usage by about 35% on the median request. This was the highest-leverage single change.
The second lever was per-block updates instead of full regeneration on user actions, which I described in Implementation 3. Action follow-ups are the majority of traffic in a stateful UI, and re-running the full plan for each action is genuinely wasteful. Replacing only the relevant block reduced action-call cost by approximately 75%.
The third lever was bounded max_tokens plus a hard block ceiling in the system prompt. We tell Claude "render no more than seven blocks per response" and set max_tokens to 4096. Without the prompt-side bound, Claude occasionally generates twelve or more blocks for the same query, which is both wasteful and harder to read. The cap rarely triggers on real queries — it is just the safety net.
A fourth lever, which we tried and rolled back, was speculative decoding via a smaller model running in parallel. The added engineering complexity was not worth the marginal gain in our workload, but if your renderer ever hits hundreds of millions of tokens per month it is worth re-evaluating.
Five pitfalls I learned the hard way
A short collection of the traps my team and I actually walked into, with the workaround inline.
The first is dropping tool_choice. Without { type: "any" } (or pinning to render_component), a sufficiently smart Claude decides "this would be friendlier in plain text," skips the tool, and breaks your renderer. Always specify it.
The second is rendering partial blocks without a stable React key. The block list is in flux during streaming. If you write blocks.map(b => <Renderer {...b} />) without a key, React will re-mount and your animations stutter. Use the block's index as the key and memoize the registry lookup.
The third is leaving Zod limits high. max(100) on table rows looks reasonable until Claude actually returns a 100-row table and the browser stops responding. Cap at 50 in the schema and virtualize with react-window on the client.
The fourth is mixed-language fields. A system prompt that says "match the user's language" is not enough — Claude will sometimes leave a single label in English. Add an explicit line: "All user-readable fields (text, label, body) must match the user's input language."
The fifth is forgetting tool calls in conversation history on regeneration. If you do not echo the prior tool_use blocks back into messages, Claude does not "remember" what it just rendered, and the regeneration drifts. Track this server-side (see Implementation 3) and at minimum keep a summary of the previous tool calls in the assistant turn.
A sixth pitfall worth naming, even though it is the most boring of the set: forgetting to send Cache-Control: no-store on the streaming response. Browsers and proxies sometimes try to be helpful and cache event-stream responses, which means the second user to make the same request gets a frozen UI from yesterday. Set the header explicitly on the route, both server-side and at any CDN tier, and add an integration test that a freshly-restarted server returns a fresh stream.
A seventh, which only manifests once you have real traffic, is concurrency on the registry. If you generate the tool definition lazily on each request, you will see a measurable CPU spike and intermittent latency outliers under load. Compute the tool definition once at module load time and keep the reference in module scope. We profiled a 12 ms median improvement and a 60 ms p95 improvement just from this change.
Your 30-minute next step
If this article gave you a path forward, here is the single concrete next step I would suggest spending 30 minutes on today.
Pick one screen in an existing product whose layout is always the same — typically a results or dashboard view. Limit the registry to three components: heading, data_table, and callout. Replace that screen with the Tool Use renderer, and run an A/B test for one week measuring time-on-page and bounce. On a reporting dashboard inside our team, this exact experiment moved average time-on-page up by 22%.
Generative UI is a flashy headline, but the real work is the unglamorous layer underneath: a tight registry, honest streaming, and a sensible fallback. Get those three right and you will quietly ship a feature that users notice for the right reasons.