Building a UI with json-render and Jev
With json-render's Jev integration you prepare component candidates that already carry their labels and data, and let the model pick and arrange them. This article walks through how it works and how it differs from ordinary json-render.
json-render is a library for Generative UI, where an AI generates the UI. Using the components and actions you define in a catalog, the AI generates JSON that describes the structure of the UI, and a framework such as React renders that JSON. The AI assembles the JSON by choosing from parts that are defined in advance. Even so, the JSON itself is generated by the model, so designing a prompt that makes it output exactly what you want has been unavoidable.
Jev, a new AI model from TypeSafe AI, does not generate prose. Instead it returns results that follow a set of predefined output candidates and a fixed format. TypeSafe AI describes the model as making structured decisions at low cost and high speed. That makes it a good fit for Generative UI, where the goal is to produce structured JSON.
In json-render's Jev integration, you pass components with concrete values already filled in as candidates, and let the model decide which part goes where. With ordinary json-render, you tell the model in prose to "create a name text box", and the model decides on the label it needs and generates new props every time. With Jev, the application prepares things ahead of time and says "for the name text box, choose from these candidates".
This article walks through actually building a UI with Jev and json-render, and explains how it differs from an ordinary json-render implementation that uses OpenAI's GPT-4.1 mini.
The Jev integration is an experimental API. APIs prefixed with experimental_ may change, so use them with care.
Generating JSON versus choosing from candidates
With ordinary json-render, you define the names of the available components and the schemas of their props in a catalog. You describe that catalog to an LLM and have it generate a Spec that represents the structure of the UI. A Spec contains the ID of the root element, the props of each element, the parent-child relationships, and so on.
For example, a catalog that registers an Input component representing a text box looks like this.
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react/schema";
import { z } from "zod";
const catalog = defineCatalog(schema, {
components: {
Input: {
props: z.object({
label: z.string(),
type: z.enum(["text", "email"]),
value: z.string(),
}),
},
},
actions: {},
});This catalog defines the names and types of the props you can pass to Input. It does not decide the concrete values, such as whether label should be "名前" (name) or "メールアドレス" (email address). You generate a description of the catalog as a prompt with catalog.prompt(), pass it to the LLM, and have it generate a Spec. That prompt is what makes the structure of the JSON the model outputs conform to the catalog definition.
import { streamText } from "ai";
import { buildUserPrompt } from "@json-render/core";
import { catalog, prompt } from "./catalog";
const result = streamText({
model: "openai/gpt-4.1-mini",
system: catalog.prompt(),
prompt: buildUserPrompt({ prompt }),
});
return result.toTextStreamResponse();Jev, on the other hand, is a model that returns decisions over a set of options. experimental_composeSpec(), which we will use here, takes those decisions and builds a Spec from them. For the name text box, for instance, you prepare a candidate like the following ahead of time.
import type { Experimental_CompositionCandidate } from "@json-render/core";
const nameCandidate = {
id: "name",
description: 'Editable name, label "名前", bound to /name',
root: false,
element: {
type: "Input",
props: {
label: "名前",
type: "text",
value: { $bindState: "/name" },
},
},
} satisfies Experimental_CompositionCandidate;What you leave to the model is limited to deciding whether to use this candidate, and which position under which parent element to place it in.
Installing the packages and preparing an API key
Let's try it for real. Add the following packages to a React project.
npm install --save-exact @json-render/[email protected] @json-render/[email protected] [email protected] [email protected]Issue a Vercel AI Gateway key and save it in .env.
AI_GATEWAY_API_KEY=your-key-hereUntil September 25, 2026, you can use Jev for free through Vercel AI Gateway.
To use Jev, the typesafe-ai provider has to be allowed for your Gateway team. Issuing a key is sometimes not enough on its own, so check the Gateway authentication settings as well.
Defining the catalog and the candidates
First, register three components in the catalog: a panel, a text box, and a button. The slots on the panel describe where child elements can go, and the events on the button describe which events it can fire.
import {
defineCatalog,
type Experimental_CompositionCandidate,
} from "@json-render/core";
import { schema } from "@json-render/react/schema";
import { z } from "zod";
export const catalog = defineCatalog(schema, {
components: {
Panel: { props: z.object({ title: z.string() }), slots: ["default"] },
Input: {
props: z.object({
label: z.string(),
type: z.enum(["text", "email"]),
value: z.string(),
}),
},
Button: { props: z.object({ label: z.string() }), events: ["press"] },
},
actions: {
savePreferences: {
params: z.object({ name: z.string(), email: z.string() }),
},
},
});savePreferences is an operation that takes a name and an email address. We will implement the handler for this definition on the React side later.
Next, add the initial state and the candidates to the same file. The name text box and the email text box are both the same Input component, but because their props differ they are defined as separate candidates.
export const initialState = { name: "Azuki", email: "[email protected]" };
export const candidates = [
{
id: "settings",
description: 'Account settings panel, title "アカウント設定"',
element: { type: "Panel", props: { title: "アカウント設定" } },
},
{
id: "name",
description: 'Editable name, label "名前", bound to /name',
root: false,
element: {
type: "Input",
props: { label: "名前", type: "text", value: { $bindState: "/name" } },
},
},
{
id: "email",
description: 'Editable email, label "メールアドレス", bound to /email',
root: false,
element: {
type: "Input",
props: {
label: "メールアドレス",
type: "email",
value: { $bindState: "/email" },
},
},
},
{
id: "save",
description:
'Save preferences button, label "保存", save current name and email',
root: false,
element: {
type: "Button",
props: { label: "保存" },
on: {
press: {
action: "savePreferences",
params: { name: { $state: "/name" }, email: { $state: "/email" } },
},
},
},
},
] satisfies Experimental_CompositionCandidate[];
export const prompt =
"アカウント設定のパネルを作ってください。名前、メールアドレス、保存ボタンの順に縦に並べてください。各テキストボックスは編集可能にし、保存ボタンで現在の名前とメールアドレスを保存してください。";$bindState is the expression that ties a text box to the state. When you edit the name, the value at /name changes. The $state given as an argument to the save button reads the state at that moment.
Candidates marked with root: false will never be chosen as the root. Here, only Panel is a candidate for the root. Deciding to place the text boxes and the button inside the panel is left to the model.
The description should carry the information needed to make that choice. In this case, it explains which candidate is the name text box and which is the email text box. The props of a candidate are matched against the schema after their expressions have been resolved using the initial state. See the constraints in the official guide for details.
Calling Jev and receiving the Spec
Create an evaluation function that connects to the Gateway with experimental_createEvaluator(), and pass it to experimental_composeSpec(). Let's use a small script to see what kind of Spec the step events return while the UI is being built.
import { writeFile } from "node:fs/promises";
import {
experimental_composeSpec,
experimental_createEvaluator,
} from "@json-render/core";
import { catalog, candidates, initialState, prompt } from "./catalog";
const evaluate = experimental_createEvaluator({
model: "typesafe-ai/jev",
apiKey: process.env.AI_GATEWAY_API_KEY!,
});
try {
for await (const event of experimental_composeSpec({
catalog,
candidates,
initialState,
prompt,
evaluate,
maxSteps: 4,
maxElements: 4,
signal: AbortSignal.timeout(30_000),
})) {
if (event.type === "step") {
console.log(event.step.choice, event.spec);
} else {
console.log("stopReason:", event.stopReason);
if (event.stopReason === "finish" && event.spec) {
await writeFile("spec.json", JSON.stringify(event.spec, null, 2));
}
}
}
} catch {
console.error(
"生成に失敗しました。Gateway の利用設定と通信状態を確認してください。",
);
process.exitCode = 1;
}Run it with the following command, which loads .env.
node --env-file=.env --import tsx src/run-jev.tsRunning it produced the following output.
select {
root: 'node_0',
elements: {
node_0: { type: 'Panel', props: [Object], children: [Array] },
node_1: { type: 'Input', props: [Object], children: [] },
node_2: { type: 'Input', props: [Object], children: [] },
node_3: { type: 'Button', props: [Object], on: [Object], children: [] }
},
state: { name: 'Azuki', email: '[email protected]' }
}
layout {
root: 'node_0',
elements: {
node_0: { type: 'Panel', props: [Object], children: [Array] },
node_1: { type: 'Input', props: [Object], children: [] },
node_2: { type: 'Input', props: [Object], children: [] },
node_3: { type: 'Button', props: [Object], on: [Object], children: [] }
},
state: { name: 'Azuki', email: '[email protected]' }
}
stopReason: finishThe select and layout at the start of each line are the values of step.choice. Although there are four candidates, only two step events occurred. That is because the strategy of experimental_composeSpec() defaults to "batch" when building a new tree. With "batch", the model evaluates which candidates to use all at once (select), and then where to place the chosen parts under which parent all at once (layout). Unlike "sequential", which repeats an evaluation for every part, the number of requests to the model does not depend on the number of elements.
Extracting the root panel and the name text box from the spec.json that was written out gives the following.
{
"root": "node_0",
"elements": {
"node_0": {
"type": "Panel",
"props": {
"title": "アカウント設定"
},
"children": [
"node_1",
"node_2",
"node_3"
]
},
"node_1": {
"type": "Input",
"props": {
"label": "名前",
"type": "text",
"value": {
"$bindState": "/name"
}
},
"children": []
}
},
"state": {
"name": "Azuki",
"email": "[email protected]"
}
}The node_0 that root points at is the Panel. Its children list the name (node_1), the email address (node_2), and the save button (node_3) in that order. Note also that the value of the name has not been replaced with a string: it keeps the state binding { "$bindState": "/name" }. The initial value Azuki lives separately, in state.name.
The complete generated spec.json
{
"root": "node_0",
"elements": {
"node_0": {
"type": "Panel",
"props": {
"title": "アカウント設定"
},
"children": [
"node_1",
"node_2",
"node_3"
]
},
"node_1": {
"type": "Input",
"props": {
"label": "名前",
"type": "text",
"value": {
"$bindState": "/name"
}
},
"children": []
},
"node_2": {
"type": "Input",
"props": {
"label": "メールアドレス",
"type": "email",
"value": {
"$bindState": "/email"
}
},
"children": []
},
"node_3": {
"type": "Button",
"props": {
"label": "保存"
},
"on": {
"press": {
"action": "savePreferences",
"params": {
"name": {
"$state": "/name"
},
"email": {
"$state": "/email"
}
}
}
},
"children": []
}
},
"state": {
"name": "Azuki",
"email": "[email protected]"
}
}The spec carried by a step event is a snapshot of the whole UI at that point in time. Rather than applying it as a diff, you use it to replace the Spec you are currently displaying. In a real web application, updating the rendering every time a step event arrives lets the screen appear gradually, starting from the parts that are already finished.
Rendering in React and passing input values to the save action
To render in React, you register the component implementations in a registry. The registry is what ties the Spec the AI produces to the actual React components. We register the same three components: the panel, the text box, and the button.
import React, { useId } from "react";
import { useBoundProp, type ComponentRegistry } from "@json-render/react";
export const registry: ComponentRegistry = {
Panel: ({ element: { props }, children }) => (
<section className="panel">
<h1>{props.title}</h1>
{children}
</section>
),
Input: ({ element: { props }, bindings }) => {
const id = useId();
const [value, setValue] = useBoundProp<string>(
props.value,
bindings?.value,
);
return (
<div className="field">
<label htmlFor={id}>{props.label}</label>
<input
id={id}
type={props.type}
value={value ?? ""}
onChange={(e) => setValue(e.target.value)}
/>
</div>
);
},
Button: ({ element: { props }, emit }) => (
<button type="button" onClick={() => emit("press")}>
{props.label}
</button>
),
};The important piece for the text box is useBoundProp(). You pass it the resolved value and the bind target, and update the state with the function it returns. The label and the text box are tied together with useId(). The button uses emit("press") to fire the action recorded in the Spec.
The spec you receive is passed to JSONUIProvider and Renderer.
import { JSONUIProvider, Renderer } from "@json-render/react";
import { registry } from "./registry";
// Somewhere above, spec is fetched from the backend API...
<fieldset disabled={loading}>
<JSONUIProvider
registry={registry}
initialState={spec.state}
handlers={{
savePreferences: (params) => {
setStatus(`保存しました: ${params.name} / ${params.email}`);
},
}}
>
<Renderer spec={spec} registry={registry} loading={loading} />
</JSONUIProvider>
</fieldset>;The UI was rendered as shown below.
Summary
- With json-render's Jev integration, you prepare candidates that carry concrete props and actions, and leave the selection and placement of the parts to the model. With ordinary json-render, you instruct the model in prose and have it generate new props
- Create an evaluation function that connects to the Gateway with
experimental_createEvaluator()and pass it toexperimental_composeSpec() - To build a
Spec,experimental_composeSpec()has the model decide which candidates to use and where to place them - When rendering in React, register the component implementations in a
registryand bind the state withuseBoundProp()




