All posts
Generative UI ·

Streaming a typed page spec through Genkit

The json-render demo emits a flat, typed element tree of ids and props that the client renders against a fixed component catalog, instead of asking the model to write JSX.

There are two ways to do generative UI. The popular one is to ask the model for a blob of JSX or HTML and hope it parses. It almost works. The failure mode is ugly: malformed tags, mystery attributes, the occasional onclick handler the model invented because it felt like the vibe.

This demo takes the other path. The model never touches markup. It emits a structured spec against a catalog of React components that already exist in the codebase, and the client walks that spec to render the page.

The catalog is an enum

The allowed components are a fixed list:

export const COMPONENT_TYPES = [
  'Hero', 'FeatureList', 'Feature',
  'Testimonial', 'PricingTier', 'CTA', 'Container',
] as const;

That enum is the only thing the model can put in an element’s type field. props is z.record(z.string(), z.unknown()), validated per-component on the client rather than at the Genkit boundary. The split is deliberate. The catalog gate runs first at the schema, then each component owns its own props contract.

Flat tree with id references

The spec is a flat array plus a root id, not a nested tree:

export const SpecSchema = z.object({
  root: z.string(),
  elements: z.array(ElementSchema).min(1),
});

Each element’s children is an array of ids, not nested objects. That shape is picked for streaming. The flow declares streamSchema: SpecSchema.deepPartial(), which means every chunk the model emits is a partial spec the client can render against, and a parent element can arrive before its children without the parse failing.

The trade-off

Flat-plus-ids is more verbose than a nested tree, and the model has to stay consistent about which ids it has emitted so far. If Gemini names a child id in children that never lands in elements, the client is left with a dangling reference. Nothing in the schema prevents that, because the schema cannot know what order chunks will arrive in. The guardrail sits one layer up, in the client reconciler that waits for a referenced id before mounting its parent’s slot.

Try the Generative UI demo More posts →