Structured Outputs and Generative UI
Turn model responses into reliable UI components with schemas, validation, partial JSON states, and safe fallbacks instead of parsing free-form text.
Interview answer
Use a schema-validated, allowlisted UI intent protocol rather than executable model-generated JSX. Reveal only validated streamed fields and render a safe fallback for invalid or unknown variants.
When a model writes a paragraph, Markdown is a good interface. When it needs to populate a price comparison, a plan, an action card, or a form, text is the wrong contract. You want data that your frontend can validate and render with ordinary components.
That is structured output plus generative UI: the model chooses from a small, typed vocabulary of UI intents; your application decides what those intents are allowed to do. The model proposes. The product owns the renderer.
01Make the model return data, not a component
Do not ask the model to write JSX and then execute it. Define a small data schema and map each allowed variant to a component you own. A support agent might return a product card, a clarification prompt, or a handoff suggestion — never arbitrary executable UI.
Start with two real choices: ask a question or suggest a handoff. You can add a comparison card later, once you know its data contract.
import { z } from 'zod';
const UiIntentSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('clarify'),
question: z.string().min(1).max(300),
choices: z.array(z.string().min(1).max(80)).min(2).max(4),
}),
z.object({
type: z.literal('handoff'),
reason: z.string().min(1).max(500),
}),
]);
type UiIntent = z.infer<typeof UiIntentSchema>;
This schema bounds what the UI can display. It does not authorize a handoff; the application still decides whether that action is available.
02A schema is necessary; runtime validation is still mandatory
Provider structured-output mode makes invalid JSON much rarer. It does not make an external response safe to trust blindly. Validate at your server boundary and validate again at the client boundary if data crosses an API or persistence boundary.
function IntentView({ payload, onChoose }: {
payload: unknown;
onChoose: (choice: string) => void;
}) {
const parsed = UiIntentSchema.safeParse(payload);
if (!parsed.success) {
return <p role="status">I couldn't format that result. Try again.</p>;
}
const intent = parsed.data;
switch (intent.type) {
case 'clarify':
return <fieldset>
<legend>{intent.question}</legend>
{intent.choices.map((choice, index) =>
<button key={index} type="button" onClick={() => onChoose(choice)}>
{choice}
</button>
)}
</fieldset>;
case 'handoff':
return <p>A human can help: {intent.reason}</p>;
}
}
The model text is rendered as text by React. The caller supplies onChoose and decides what a click means; a model-supplied label is not an instruction to execute a privileged action.
03Streaming JSON is not yet an object
During streaming, {"type":"comparison","items":[ is not parseable JSON. Do not repeatedly call JSON.parse on every delta and treat exceptions as product logic. Either use a protocol that emits typed field events, or keep the raw stream in a buffer and render only fields that have passed validation.
For the example above, keep “Preparing answer…” visible until you have a complete candidate. Then run safeParse and render the card. If you need progressive fields, define a separate event contract such as {"event":"choice","value":"Email"}; validate each complete event before appending it. Never treat a half-received JSON property as a committed choice.
04Follow one result from model to screen
Suppose the user writes, “I need help with a refund.” The model returns {"type":"clarify","question":"Was the purchase made in the last 30 days?","choices":["Yes","No"]}. The server first checks that the response completed and was not a refusal, then parses and validates it. The client receives a versioned intent and validates it again at its API boundary. Only then does IntentView render the question and two product-owned controls.
Now remove choices from the same payload. The page should show the fallback and log a validation failure, not an empty fieldset. If the model instead returns handoff, the UI can explain why a person may help; it still does not open a ticket until your own action handler and policy allow that. This one example is worth rehearsing because it makes “the model proposes, the app decides” tangible.
05Design for the fallback before the happy path
Your renderer needs an intentional answer for unknown types, missing fields, oversized lists, refusals, truncated responses, and a schema version from a newer backend. A readable fallback with a retry button is usually enough. Do not try to invent a component at runtime.
- Unknown type: use a generic result card and record the schema version.
- Missing required field: keep the previous valid state or show a retry state.
- Too many items: cap the display and offer “show more.”
- Unsafe value: render it as text, never as raw HTML or a URL without validation.
06Measure schema quality separately from model quality
Test this with a valid clarification, a missing choices array, five choices, an unknown type, a refusal, and a stream that ends halfway through an object. The user should get a useful state every time, not a thrown parser error.
Track validation failure rate, fallback rate, unknown variant rate, and time until the first useful field renders. Those metrics tell you whether your schema and prompt are working. “The model looked good in a demo” is not an operational metric.
The model chooses an intent from a small vocabulary. Your code validates it and renders ordinary components. That is the heart of generative UI.
Key Takeaways
- 01Models should return typed UI intent data, not executable JSX or arbitrary HTML.
- 02JSON mode does not guarantee a schema; even with Structured Outputs, validate and provide a visible fallback.
- 03Partial JSON is not valid application state; stream typed events or only reveal validated fields.
- 04Render from an allowlisted discriminated union so product code retains control of behavior and accessibility.
- 05Track validation and fallback rates to improve the protocol instead of judging the feature by a few demos.