json-render interop
Use GenUI components inside json-render, so a model writing a json-render spec can place them in its UI.
json-render is an open-source generative UI framework from Vercel Labs, released under the Apache-2.0 license. The model writes one flat JSON spec ({ root, elements, state }) using only the components in a catalog you define, and json-render renders it as it streams. Values move between elements through a state model: $bindState writes, $state reads.
genui-json-render.ts turns GenUI components into json-render catalog entries and registry components, so a json-render app can use them next to its own.
Haircut at Northbeam Barbers
45 minNew York time (GMT-4)
Book the haircut
Low riskThe spec
{
"root": "page",
"state": {
"booking": {
"slot": {
"label": "Pick a time above"
}
}
},
"elements": {
"page": {
"type": "Stack",
"props": {},
"children": [
"pick",
"confirm"
]
},
"pick": {
"type": "PickSlot",
"props": {
"title": "Haircut at Northbeam Barbers",
"timezone": "America/New_York",
"durationLabel": "45 min",
"submitLabel": "Use this time",
"days": [
{
"date": "2026-10-12",
"slots": [
{
"id": "2026-10-12T09:30",
"start": "09:30",
"available": true
},
{
"id": "2026-10-12T11:00",
"start": "11:00",
"available": false
},
{
"id": "2026-10-12T15:00",
"start": "15:00",
"available": true
}
]
},
{
"date": "2026-10-13",
"slots": [
{
"id": "2026-10-13T10:00",
"start": "10:00",
"available": true
},
{
"id": "2026-10-13T16:30",
"start": "16:30",
"available": true
}
]
}
],
"result": {
"$bindState": "/booking/slot"
}
},
"children": []
},
"confirm": {
"type": "RequestApproval",
"props": {
"action": "Book the haircut",
"risk": "low",
"reversible": true,
"undoWindow": "Cancel free up to 24 hours before",
"scope": [
{
"label": "When",
"value": {
"$state": "/booking/slot/label"
}
},
{
"label": "Where",
"value": "Northbeam Barbers"
}
]
},
"children": []
}
}
}The demo is a spec as a model would write it: a json-render Stack holding two GenUI components. The slot picker's result is bound to /booking/slot, and the approval reads /booking/slot/label, so picking a time fills the confirmation with no call to the model.
Tool calls or json-render?
Use GenUI with tool calls (the default) when your agent calls tools. Each component is one tool call, the user's decision goes back as that call's tool result, and the Renderer handles the lifecycle states (failed, stopped, stale, expired, superseded, empty, partial).
Use GenUI inside json-render when your app already renders json-render specs, or you want one spec format across web, mobile, email and PDF. The model composes the whole UI as one spec; GenUI supplies the designed task components (a slot picker, an approval, a comparison) that a catalog of primitives doesn't have.
Installation
pnpm add @json-render/core @json-render/reactThen add the Renderer files, which include genui-json-render.ts:
npx shadcn@latest add https://your-site.com/r/renderer.json
genui-json-render.ts doesn't import any @json-render package. You pass in the one hook it needs (useStateStore), so it works with the version you installed. It was checked against 0.21.0.
Define the catalog and registry
import { defineCatalog } from "@json-render/core"
import { schema } from "@json-render/react/schema"
import { defineRegistry, useStateStore } from "@json-render/react"
import { toJsonRenderCatalog, toJsonRenderComponents } from "@/components/genui/genui-json-render"
import { slotPickerGuidance, slotPickerTool } from "@/components/genui/slot-picker.schema"
import { approvalRequestGuidance, approvalRequestTool } from "@/components/genui/approval-request.schema"
import { SlotPicker } from "@/components/genui/slot-picker"
import { ApprovalRequest } from "@/components/genui/approval-request"
const entries = [
{ tool: slotPickerTool, guidance: slotPickerGuidance },
{ tool: approvalRequestTool, guidance: approvalRequestGuidance },
]
export const catalog = defineCatalog(schema, {
components: {
...myComponents, // your own: Stack, Card, Heading…
...toJsonRenderCatalog({ entries }).components,
},
actions: {},
})
export const { registry } = defineRegistry(catalog, {
components: {
...myRegistryComponents,
...toJsonRenderComponents({
entries,
components: { pick_slot: SlotPicker, request_approval: ApprovalRequest },
jsonRender: { useStateStore },
}),
},
})Components are named from the tool name in PascalCase (pick_slot → PickSlot); pass names to choose your own, for example to avoid a clash with a component you already have. Every GenUI component is a leaf (slots: []).
GenUI's layouts (stack, grid, split, steps, tabs, columns) are left out: in json-render the spec tree is the layout, built from your containers. Pass includeLayouts: true if you want them anyway.
The prompt
json-render builds the system prompt from the catalog. Each GenUI component's description carries what it is, when to use it and when not to; its rules go in customRules:
import { jsonRenderRules } from "@/components/genui/genui-json-render"
const system = catalog.prompt({ customRules: jsonRenderRules(entries) })jsonRenderRules also tells the model GenUI's data rule (never invent data-bound fields) and how to carry a result from one element to another.
Results
When the user acts on a GenUI component, three things happen:
- State. Every GenUI component gets an extra
resultprop. If the model writes"result": { "$bindState": "/booking/slot" }, the result is stored at that path, and any element can read it:{ "$state": "/booking/slot/label" }. This is json-render's form of GenUI's binds. Bind to display-ready fields, such as the slot picker'slabel. - Your app.
GenUIJsonRenderResultsreceives{ tool, component, result }. Wrap the renderer in it to send results to your agent:
import { JSONUIProvider, Renderer } from "@json-render/react"
import { GenUIJsonRenderResults } from "@/components/genui/genui-json-render"
<GenUIJsonRenderResults.Provider value={({ tool, result }) => sendToAgent(tool, result)}>
<JSONUIProvider registry={registry} initialState={spec.state}>
<Renderer spec={spec} registry={registry} loading={isStreaming} />
</JSONUIProvider>
</GenUIJsonRenderResults.Provider>- Spec actions. The element's
resultevent fires, so the model can chain one of your catalog actions with"on": { "result": { "action": "…" } }.
A result already in state when the element mounts restores the component's final state, so a reloaded spec shows what the user chose. Only a real result counts (every GenUI result has an action), so state can hold a placeholder like { "label": "Pick a time above" } for the elements that read it.
What carries over, and what doesn't
| Inside json-render | |
|---|---|
| Props, guidance, typed results | Carried over |
| Streaming | json-render's loading becomes status: "streaming" |
Models writing null for an optional prop | Dropped, unless the field accepts null |
| Binds | Use $bindState and $state instead |
| Lifecycle states (stale, expired, superseded, stopped) | Not available: they belong to the Renderer, which json-render replaces |
| Layouts | Left out by default; use your containers |
json-render also has an experimental mode where a decision model (Jev) picks among prepared components instead of writing the spec. Its APIs are unreleased and it needs a Vercel AI Gateway key; GenUI components would be candidates like any others, but this adapter doesn't cover it yet.