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)

Day

October 2026

Times on Monday, October 12

Book the haircut

Low risk
When
Pick a time above
Where
Northbeam Barbers

Cancel free up to 24 hours before

Waiting for your decision

The 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/react

Then 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

lib/json-render.ts
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:

  1. State. Every GenUI component gets an extra result prop. 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's label.
  2. Your app. GenUIJsonRenderResults receives { 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>
  1. Spec actions. The element's result event 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 resultsCarried over
Streamingjson-render's loading becomes status: "streaming"
Models writing null for an optional propDropped, unless the field accepts null
BindsUse $bindState and $state instead
Lifecycle states (stale, expired, superseded, stopped)Not available: they belong to the Renderer, which json-render replaces
LayoutsLeft 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.

Source

On this page