Json Render Catalog
>-
Auto-activated โ this skill loads automatically when Claude detects matching context.
json-render Component Catalogs
json-render (Vercel Labs, 12.9K stars, Apache-2.0) is a framework for AI-safe generative UI. AI generates flat-tree JSON (or YAML) specs constrained to a developer-defined catalog โ the catalog is the contract between your design system and AI output. If a component or prop is not in the catalog, AI cannot generate it.
Quick Reference
| Category | Rules | Impact | When to Use |
|---|---|---|---|
| Catalog Definition | 1 | HIGH | Defining component catalogs with Zod |
| Prop Constraints | 1 | HIGH | Constraining AI-generated props for safety |
| shadcn Catalog | 1 | MEDIUM | Using pre-built shadcn components |
| Token Optimization | 1 | MEDIUM | Reducing token usage with YAML mode |
| Actions & State | 1 | MEDIUM | Adding interactivity to specs |
Total: 5 rules across 5 categories
How json-render Works
- Developer defines a catalog โ Zod-typed component definitions with constrained props
- AI generates a spec โ flat-tree JSON/YAML referencing only catalog components
- Runtime renders the spec โ
<Render>component validates and renders each element
The catalog is the safety boundary. AI can only reference types that exist in the catalog, and props are validated against Zod schemas at runtime. This prevents hallucinated components and invalid props from reaching the UI.
Quick Start โ 3 Steps
Step 1: Define a Catalog
import { defineCatalog } from '@json-render/core'
import { z } from 'zod'
export const catalog = defineCatalog({
Card: {
props: z.object({
title: z.string(),
description: z.string().optional(),
}),
children: true,
},
Button: {
props: z.object({
label: z.string(),
variant: z.enum(['default', 'destructive', 'outline', 'ghost']),
}),
children: false,
},
StatGrid: {
props: z.object({
items: z.array(z.object({
label: z.string(),
value: z.string(),
trend: z.enum(['up', 'down', 'flat']).optional(),
})).max(20),
}),
children: false,
},
})Step 2: Implement Components
import type { CatalogComponents } from '@json-render/react'
import type { catalog } from './catalog'
export const components: CatalogComponents<typeof catalog> = {
Card: ({ title, description, children }) => (
<div className="rounded-lg border p-4">
<h3 className="font-semibold">{title}</h3>
{description && <p className="text-muted-foreground">{description}</p>}
{children}
</div>
),
Button: ({ label, variant }) => (
<button className={cn('btn', `btn-${variant}`)}>{label}</button>
),
StatGrid: ({ items }) => (
<div className="grid grid-cols-3 gap-4">
{items.map((item) => (
<div key={item.label}>
<span>{item.label}</span>
<strong>{item.value}</strong>
</div>
))}
</div>
),
}Step 3: Render a Spec
import { Render } from '@json-render/react'
import { catalog } from './catalog'
import { components } from './components'
function App({ spec }: { spec: JsonRenderSpec }) {
return <Render catalog={catalog} components={components} spec={spec} />
}Spec Format
The JSON spec is a flat tree โ no nesting, just IDs and references. Load references/spec-format.md for full documentation.
{
"root": "card-1",
"elements": {
"card-1": {
"type": "Card",
"props": { "title": "Dashboard" },
"children": ["chart-1", "btn-1"]
},
"btn-1": {
"type": "Button",
"props": { "label": "View Details", "variant": "default" }
}
}
}With Interactivity (on / watch / state)
{
"root": "card-1",
"elements": {
"card-1": {
"type": "Card",
"props": { "title": "Dashboard" },
"children": ["chart-1", "btn-1"],
"on": { "press": { "action": "setState", "path": "/view", "value": "detail" } },
"watch": { "/data": { "action": "load_data", "url": "/api/stats" } }
}
},
"state": { "/activeTab": "overview" }
}Load rules/action-state.md for event handlers, watch bindings, and state adapter patterns.
YAML Mode โ 30% Fewer Tokens
For one-shot (non-streaming) generation, YAML specs use ~30% fewer tokens than JSON:
root: card-1
elements:
card-1:
type: Card
props:
title: Dashboard
children: [chart-1, btn-1]
btn-1:
type: Button
props:
label: View Details
variant: defaultUse JSON for streaming (JSON Patch RFC 6902 over JSONL requires JSON). Use YAML for one-shot generation where token cost matters. Load rules/token-optimization.md for selection criteria.
Progressive Streaming
json-render supports progressive rendering during streaming. As the AI generates spec elements, they render immediately โ the user sees the UI building in real-time. This uses JSON Patch (RFC 6902) operations streamed over JSONL:
{"op":"add","path":"/elements/card-1","value":{"type":"Card","props":{"title":"Dashboard"},"children":[]}}
{"op":"add","path":"/elements/btn-1","value":{"type":"Button","props":{"label":"Save","variant":"default"}}}
{"op":"add","path":"/elements/card-1/children/-","value":"btn-1"}Elements render as soon as their props are complete โ no waiting for the full spec.
@json-render/shadcn โ 36 Pre-Built Components
The @json-render/shadcn package provides a production-ready catalog of 36 components with Zod schemas already defined. Load rules/shadcn-catalog.md for the full component list and when to extend vs use as-is.
import { shadcnCatalog, shadcnComponents } from '@json-render/shadcn'
import { mergeCatalogs } from '@json-render/core'
// Use as-is
<Render catalog={shadcnCatalog} components={shadcnComponents} spec={spec} />
// Or merge with custom components
const catalog = mergeCatalogs(shadcnCatalog, customCatalog)Package Ecosystem
23 packages covering web, mobile, 3D, codegen, and state management. Load references/package-ecosystem.md for the full list organized by category.
When to Use vs When NOT to Use
Use json-render when:
- AI generates UI and you need to constrain what it can produce
- You want runtime-validated specs that prevent hallucinated components
- You need cross-platform rendering (React, Vue, Svelte, React Native, PDF, email)
- You are building generative UI features (dashboards, reports, forms from natural language)
Do NOT use json-render when:
- Building static, developer-authored UI โ use components directly
- AI generates code (JSX/TSX) rather than specs โ use standard code generation
- You need full creative freedom without catalog constraints โ json-render is deliberately restrictive
- Performance-critical rendering with thousands of elements โ the flat-tree abstraction adds overhead
Migrating from Custom GenUI
If you have existing custom generative UI (hand-rolled JSON-to-component mapping), load references/migration-from-genui.md for a step-by-step migration guide.
Rule Details
Catalog Definition
How to define catalogs with defineCatalog() and Zod schemas.
| Rule | File | Key Pattern |
|---|---|---|
| Catalog Definition | rules/catalog-definition.md | defineCatalog with Zod schemas, children types |
Prop Constraints
Constraining props to prevent AI hallucination.
| Rule | File | Key Pattern |
|---|---|---|
| Prop Constraints | rules/prop-constraints.md | z.enum, z.string().max(), z.array().max() |
shadcn Catalog
Using the 36 pre-built shadcn components.
| Rule | File | Key Pattern |
|---|---|---|
| shadcn Catalog | rules/shadcn-catalog.md | @json-render/shadcn components and extension |
Token Optimization
Choosing JSON vs YAML for token efficiency.
| Rule | File | Key Pattern |
|---|---|---|
| Token Optimization | rules/token-optimization.md | YAML for one-shot, JSON for streaming |
Actions & State
Adding interactivity with events, watchers, and state.
| Rule | File | Key Pattern |
|---|---|---|
| Action & State | rules/action-state.md | on events, watch reactivity, state adapters |
Key Decisions
| Decision | Recommendation |
|---|---|
| Custom vs shadcn catalog | Start with shadcn, extend with custom types for domain-specific components |
| JSON vs YAML spec format | YAML for one-shot (30% fewer tokens), JSON for streaming |
| Zod constraint strictness | Tighter is better โ use z.enum over z.string, z.array().max() over unbounded |
| State management adapter | Match your app's existing state library (Zustand, Redux, Jotai, XState) |
Common Mistakes
- Using
z.any()orz.unknown()in catalog props โ defeats the purpose of catalog constraints, AI can generate anything - Always using JSON specs โ wastes 30% tokens when streaming is not needed
- Nesting component definitions โ json-render uses a flat tree; all elements are siblings referenced by ID
- Skipping
mergeCatalogs()when combining shadcn + custom โ manual merging loses type safety - Not setting
.max()on arrays โ AI can generate unbounded lists that break layouts
Related Skills
ork:ai-ui-generationโ AI-assisted UI generation patterns for v0, Bolt, Cursorork:ui-componentsโ shadcn/ui component patterns and CVA variantsork:component-searchโ Finding and evaluating React/Vue componentsork:design-to-codeโ Converting designs to production code
Rules (5)
Actions and State Bindings in Specs โ MEDIUM
Actions and State Bindings in Specs
json-render specs support three interactivity primitives: on (event handlers), watch (reactive data bindings), and state (shared state tree). These turn static specs into interactive UIs without AI generating imperative code.
Incorrect:
{
"root": "form-1",
"elements": {
"form-1": {
"type": "Card",
"props": { "title": "Settings" },
"children": ["btn-1"]
},
"btn-1": {
"type": "Button",
"props": { "label": "Save" }
}
}
}No interactivity โ button click does nothing, no state, no data loading.
Correct:
{
"root": "form-1",
"elements": {
"form-1": {
"type": "Card",
"props": { "title": "Settings" },
"children": ["toggle-1", "btn-1"],
"watch": {
"/settings": {
"action": "load_data",
"url": "/api/settings"
}
}
},
"toggle-1": {
"type": "Switch",
"props": { "label": "Dark Mode", "checked": false },
"on": {
"change": {
"action": "setState",
"path": "/settings/darkMode",
"value": "toggle"
}
}
},
"btn-1": {
"type": "Button",
"props": { "label": "Save", "variant": "default" },
"on": {
"press": {
"action": "submit",
"url": "/api/settings",
"method": "POST",
"body": { "$ref": "/settings" }
}
}
}
},
"state": {
"/settings": {
"darkMode": false,
"notifications": true
}
}
}The Three Primitives
on โ Event Handlers:
Attached to elements, fire on user interaction.
| Event | Triggers When |
|---|---|
press | Button click / tap |
change | Input, Select, Switch, Checkbox value change |
submit | Form submission |
focus / blur | Element focus state changes |
watch โ Reactive Data Bindings:
Attached to elements, react to state path changes. Supports action, url, and optional interval (polling in ms).
"watch": { "/data/stats": { "action": "load_data", "url": "/api/stats", "interval": 30000 } }state โ Shared State Tree:
Top-level object with JSON Pointer paths. All setState and watch bindings reference these paths.
"state": { "/activeTab": "overview", "/filters": { "status": "all" }, "/data": null }Built-in Actions
| Action | Description | Example |
|---|---|---|
setState | Set a state path to a value | \{ "action": "setState", "path": "/tab", "value": "settings" \} |
load_data | Fetch data from URL into state | \{ "action": "load_data", "url": "/api/data" \} |
submit | POST/PUT data to URL | \{ "action": "submit", "url": "/api/save", "method": "POST" \} |
navigate | Client-side navigation | \{ "action": "navigate", "to": "/dashboard" \} |
toggle | Toggle boolean state path | \{ "action": "setState", "path": "/open", "value": "toggle" \} |
State Adapters
Connect json-render state to your app's state management:
// Zustand adapter
import { createZustandAdapter } from '@json-render/zustand'
const adapter = createZustandAdapter(useAppStore)
<Render catalog={catalog} components={components} spec={spec} stateAdapter={adapter} />
// Redux adapter
import { createReduxAdapter } from '@json-render/redux'
const adapter = createReduxAdapter(store)
// Jotai adapter
import { createJotaiAdapter } from '@json-render/jotai'
const adapter = createJotaiAdapter()
// XState adapter
import { createXStateAdapter } from '@json-render/xstate'
const adapter = createXStateAdapter(machine)Key rules:
- Use
onfor user-initiated actions (clicks, input changes) โ never for data loading - Use
watchfor reactive data fetching โ it re-fetches when the watched state path changes - Define all state paths in the top-level
stateobject โ referencing undefined paths is a runtime error - Use
$refin action bodies to reference state paths:"body": \{ "$ref": "/formData" \} - Choose the state adapter that matches your existing state management library โ do not mix adapters
Reference: https://github.com/nicholasgriffintn/json-render
Catalog Definition with defineCatalog and Zod โ HIGH
Catalog Definition with defineCatalog and Zod
Every json-render project starts with a catalog โ a Zod-typed registry of components the AI is allowed to generate. The catalog is the contract: if a type is not in the catalog, it cannot appear in specs.
Incorrect:
// No type safety โ AI can generate anything, props are unchecked
const components = {
Card: ({ title, children }) => <div>{title}{children}</div>,
Button: ({ label }) => <button>{label}</button>,
}
// Rendering without a catalog โ no validation
function App({ spec }) {
return <DynamicRenderer components={components} spec={spec} />
}Correct:
import { defineCatalog } from '@json-render/core'
import { z } from 'zod'
export const catalog = defineCatalog({
Card: {
// props: Zod schema validates every prop AI generates
props: z.object({
title: z.string().max(100),
description: z.string().max(500).optional(),
elevated: z.boolean().default(false),
}),
// children: true = accepts child elements, false = leaf node
children: true,
},
Button: {
props: z.object({
label: z.string().max(50),
variant: z.enum(['default', 'destructive', 'outline', 'ghost']),
size: z.enum(['sm', 'md', 'lg']).default('md'),
disabled: z.boolean().default(false),
}),
children: false,
},
DataTable: {
props: z.object({
columns: z.array(z.object({
key: z.string(),
label: z.string(),
sortable: z.boolean().default(false),
})).min(1).max(12),
rows: z.array(z.record(z.string())).max(100),
}),
children: false,
},
})
// Type-safe rendering with catalog validation
import { Render } from '@json-render/react'
<Render catalog={catalog} components={components} spec={spec} />Children Types
| Value | Meaning | Use For |
|---|---|---|
true | Accepts any catalog children | Layout components (Card, Section, Grid) |
false | Leaf node, no children | Data display (StatGrid, Chart, Badge) |
['Button', 'Badge'] | Only accepts specific types as children | Constrained containers (Toolbar accepts only Button) |
Toolbar: {
props: z.object({ orientation: z.enum(['horizontal', 'vertical']) }),
children: ['Button', 'Badge'], // Only Button and Badge allowed as children
},Merging Catalogs
Use mergeCatalogs() to combine the shadcn base with custom domain components:
import { mergeCatalogs } from '@json-render/core'
import { shadcnCatalog } from '@json-render/shadcn'
const appCatalog = mergeCatalogs(shadcnCatalog, {
PricingCard: {
props: z.object({
plan: z.enum(['free', 'pro', 'enterprise']),
price: z.string(),
features: z.array(z.string()).max(10),
}),
children: false,
},
})Key rules:
- Every component in the catalog must have a
propsZod schema and achildrendeclaration - Use
.max(),.min(), and.default()on all schemas to bound what AI can generate - Use typed children arrays (
['Button']) for containers that should only accept specific child types - Use
mergeCatalogs()to combine catalogs โ manual spreading loses runtime validation - Export the catalog type for use in component implementations:
type AppCatalog = typeof catalog
Reference: https://github.com/nicholasgriffintn/json-render
Prop Constraints for AI Safety โ HIGH
Prop Constraints for AI Safety
Catalog props are the primary defense against AI hallucination. Every prop should be as tightly constrained as possible โ bounded strings, explicit enums, capped arrays. The tighter the constraints, the more predictable and safe the AI output.
Incorrect:
// z.any() defeats the entire purpose of the catalog
BadComponent: {
props: z.object({
data: z.any(), // AI can put anything here
items: z.array(z.unknown()), // Unbounded, untyped list
color: z.string(), // AI hallucinates hex codes, CSS names, anything
content: z.string(), // No length limit โ AI can generate 10K chars
config: z.record(z.any()), // Open-ended object
}),
children: true,
},Correct:
GoodComponent: {
props: z.object({
// z.enum bounds choices to known-safe values
variant: z.enum(['primary', 'secondary', 'destructive']),
size: z.enum(['sm', 'md', 'lg']),
status: z.enum(['active', 'inactive', 'pending']),
// z.string().max() prevents unbounded text
title: z.string().min(1).max(100),
description: z.string().max(500).optional(),
// z.array().max() caps list length to prevent layout overflow
items: z.array(z.object({
label: z.string().max(50),
value: z.string().max(100),
})).min(1).max(20),
// z.number() with range for numeric props
columns: z.number().int().min(1).max(6),
progress: z.number().min(0).max(100),
// z.boolean() with default for optional flags
disabled: z.boolean().default(false),
loading: z.boolean().default(false),
}),
children: false,
},Constraint Patterns by Prop Type
| Prop Type | Weak (avoid) | Strong (use) |
|---|---|---|
| Text content | z.string() | z.string().min(1).max(200) |
| Color/variant | z.string() | z.enum(['primary', 'secondary']) |
| List items | z.array(z.any()) | z.array(schema).min(1).max(20) |
| Numeric | z.number() | z.number().int().min(0).max(100) |
| Boolean flags | (no constraint) | z.boolean().default(false) |
| Freeform object | z.record(z.any()) | z.object(\{ specific: z.string() \}) |
| URL/image | z.string() | z.string().url().max(2048) |
Refinements for Complex Validation
DateRange: {
props: z.object({
start: z.string().date(),
end: z.string().date(),
}).refine(
(data) => new Date(data.end) > new Date(data.start),
{ message: 'end must be after start' }
),
children: false,
},Key rules:
- Never use
z.any(),z.unknown(), or barez.record()in catalog props โ these bypass AI safety - Always set
.max()on strings and arrays to prevent unbounded generation - Use
z.enum()instead ofz.string()whenever possible โ enums constrain AI to valid values - Add
.default()to optional boolean and enum props โ prevents undefined gaps in rendered output - Use
.refine()for cross-field validation (date ranges, conditional requirements) - Test constraints by checking: "Can AI generate a value that would break my UI?" If yes, tighten the schema
Reference: https://zod.dev
shadcn Catalog โ 36 Pre-Built Components โ MEDIUM
shadcn Catalog โ 36 Pre-Built Components
@json-render/shadcn provides 36 components with Zod schemas and implementations ready to use. Start here before building custom catalog entries โ these components cover most dashboard, form, and content display needs.
Incorrect:
// Building from scratch when shadcn already provides it
import { defineCatalog } from '@json-render/core'
import { z } from 'zod'
const catalog = defineCatalog({
// Reimplementing what shadcn already has
Alert: {
props: z.object({
title: z.string(),
description: z.string(),
variant: z.enum(['default', 'destructive']),
}),
children: false,
},
// ... 35 more hand-rolled components
})Correct:
import { shadcnCatalog, shadcnComponents } from '@json-render/shadcn'
import { mergeCatalogs } from '@json-render/core'
import { z } from 'zod'
// Use shadcn as-is for standard components
<Render catalog={shadcnCatalog} components={shadcnComponents} spec={spec} />
// Extend with domain-specific components only
const appCatalog = mergeCatalogs(shadcnCatalog, {
PricingCard: {
props: z.object({
plan: z.enum(['free', 'pro', 'enterprise']),
price: z.string().max(20),
features: z.array(z.string().max(100)).max(10),
}),
children: false,
},
})The 36 shadcn Components
Layout & Container:
| Component | Key Props | Children |
|---|---|---|
| Card | title, description, footer | true |
| Accordion | type (single/multiple), collapsible | true |
| Tabs | defaultValue, orientation | true |
| Sheet | side (top/right/bottom/left) | true |
| Dialog | title, description | true |
| Collapsible | open, defaultOpen | true |
| Separator | orientation, decorative | false |
| ScrollArea | orientation | true |
| AspectRatio | ratio | true |
Data Display:
| Component | Key Props | Children |
|---|---|---|
| Table | columns, rows, caption | false |
| Badge | variant, label | false |
| Avatar | src, alt, fallback | false |
| Progress | value, max | false |
| Skeleton | width, height, variant | false |
| HoverCard | triggerText | true |
| Tooltip | content, side | true |
Form & Input:
| Component | Key Props | Children |
|---|---|---|
| Button | label, variant, size, disabled | false |
| Input | placeholder, type, label | false |
| Textarea | placeholder, rows, label | false |
| Select | options, placeholder, label | false |
| Checkbox | label, checked, disabled | false |
| RadioGroup | options, defaultValue, label | false |
| Switch | label, checked, disabled | false |
| Slider | min, max, step, defaultValue | false |
| Label | text, htmlFor | false |
| Toggle | label, pressed, variant | false |
| ToggleGroup | type, items | false |
Navigation:
| Component | Key Props | Children |
|---|---|---|
| NavigationMenu | items | false |
| Breadcrumb | items, separator | false |
| Menubar | menus | false |
| DropdownMenu | triggerLabel, items | false |
| ContextMenu | items | true |
| Command | placeholder, groups | false |
Feedback:
| Component | Key Props | Children |
|---|---|---|
| Alert | title, description, variant | false |
| AlertDialog | title, description, actionLabel | false |
| Toast | title, description, variant, action | false |
When to Extend vs Use As-Is
| Scenario | Approach |
|---|---|
| Standard UI (dashboards, settings, forms) | Use shadcn as-is |
| Domain-specific display (pricing, metrics, timelines) | Add custom components via mergeCatalogs |
| Branded components (custom design system) | Override shadcn implementations, keep schemas |
| Highly specialized (3D, charts, maps) | Add custom + use @json-render/react-three-fiber |
Key rules:
- Start with
shadcnCatalogโ it covers 80% of common UI patterns out of the box - Use
mergeCatalogs()to add domain-specific components alongside shadcn - Override implementations (not schemas) when you need branded styling on standard components
- Check the shadcn component list before creating a custom catalog entry โ avoid reimplementing existing components
Reference: https://ui.shadcn.com
Token Optimization โ YAML Mode โ MEDIUM
Token Optimization โ YAML Mode
json-render supports both JSON and YAML spec formats. YAML uses ~30% fewer tokens than equivalent JSON because it eliminates braces, brackets, quotes around keys, and trailing commas. For one-shot generation, YAML is the default choice.
Incorrect:
// Always using JSON regardless of use case โ wastes tokens
const systemPrompt = `Generate a json-render spec in JSON format:
{
"root": "card-1",
"elements": {
"card-1": {
"type": "Card",
"props": {
"title": "Welcome",
"description": "Getting started guide"
},
"children": ["btn-1", "btn-2"]
},
"btn-1": {
"type": "Button",
"props": {
"label": "Continue",
"variant": "default"
}
},
"btn-2": {
"type": "Button",
"props": {
"label": "Skip",
"variant": "ghost"
}
}
}
}`
// ~180 tokens for syntax overheadCorrect:
// YAML for one-shot generation โ 30% fewer tokens
import { parseYamlSpec } from '@json-render/yaml'
const systemPrompt = `Generate a json-render spec in YAML format:
root: card-1
elements:
card-1:
type: Card
props:
title: Welcome
description: Getting started guide
children: [btn-1, btn-2]
btn-1:
type: Button
props:
label: Continue
variant: default
btn-2:
type: Button
props:
label: Skip
variant: ghost`
// Parse YAML spec before rendering
const spec = parseYamlSpec(yamlString)
<Render catalog={catalog} components={components} spec={spec} />Format Selection Criteria
| Criterion | JSON | YAML |
|---|---|---|
| Streaming (progressive render) | Required | Not supported |
| One-shot generation | Works but wasteful | 30% fewer tokens |
| Token cost sensitivity | Higher | Lower |
| Parsing reliability | Native JSON.parse | Requires yaml parser |
| AI familiarity | Higher (more training data) | High (common in configs) |
| Spec debugging | Easy (structured) | Easy (readable) |
Decision Rule
If streaming (progressive render needed) โ JSON
If one-shot AND token cost matters โ YAML
If one-shot AND debugging matters โ either (both readable)
Default for one-shot โ YAMLToken Comparison Example
A spec with 5 components:
- JSON: ~450 tokens
- YAML: ~310 tokens
- Savings: ~140 tokens (31%)
At scale (100 specs/day, $3/M input tokens with Haiku): ~$0.04/day savings. The real value is in output token reduction โ LLMs generate fewer tokens in YAML format, which reduces latency.
Key rules:
- Use YAML for one-shot generation โ it reduces both input and output tokens by ~30%
- Use JSON when streaming is required โ JSON Patch (RFC 6902) operates on JSON, not YAML
- Import
parseYamlSpecfrom@json-render/yamlto convert YAML strings to spec objects - Do not mix formats in a single spec โ pick one and stay consistent
- Measure token usage with your provider's tokenizer to validate savings for your specific catalogs
Reference: https://github.com/nicholasgriffintn/json-render
References (3)
Migrating from Custom GenUI to json-render
Migrating from Custom GenUI to json-render
Guide for migrating hand-rolled generative UI systems (custom JSON-to-component mapping) to json-render catalogs. The migration adds Zod validation, streaming, and cross-platform support without rewriting component implementations.
Common Custom GenUI Patterns
Most custom GenUI systems follow one of these patterns:
Pattern A: Direct Type Mapping
// Custom: components map keyed by string type
const componentMap = {
'card': CardComponent,
'button': ButtonComponent,
'table': TableComponent,
}
function render(spec) {
const Component = componentMap[spec.type]
return <Component {...spec.props}>{spec.children?.map(render)}</Component>
}Pattern B: Switch Statement
function renderElement(element) {
switch (element.type) {
case 'card': return <Card {...element.props} />
case 'button': return <Button {...element.props} />
default: return null // Silent failure on unknown types
}
}Pattern C: Nested JSON
{
"type": "card",
"props": { "title": "Dashboard" },
"children": [
{
"type": "button",
"props": { "label": "Save" },
"children": []
}
]
}Migration Steps
Step 1: Inventory Existing Components
List every component type your current system supports, along with the props each accepts:
// Audit your component map / switch statement
const inventory = [
{ type: 'card', props: ['title', 'description', 'elevated'], hasChildren: true },
{ type: 'button', props: ['label', 'variant', 'onClick'], hasChildren: false },
{ type: 'table', props: ['columns', 'data', 'sortable'], hasChildren: false },
]Step 2: Define Zod Schemas for Each Component
Convert loosely-typed props to Zod schemas:
// Before: untyped props โ AI can pass anything
{ type: 'card', props: { title: 'anything', extra: 'unknown-prop' } }
// After: Zod-constrained catalog
import { defineCatalog } from '@json-render/core'
import { z } from 'zod'
export const catalog = defineCatalog({
Card: {
props: z.object({
title: z.string().max(100),
description: z.string().max(500).optional(),
elevated: z.boolean().default(false),
}),
children: true,
},
Button: {
props: z.object({
label: z.string().max(50),
variant: z.enum(['default', 'destructive', 'outline', 'ghost']),
// Note: onClick is NOT in the catalog โ use on.press instead
}),
children: false,
},
})Step 3: Flatten Nested Specs
Convert nested JSON to flat-tree format:
// Before: nested
{
"type": "card",
"props": { "title": "Dashboard" },
"children": [
{ "type": "button", "props": { "label": "Save" } }
]
}
// After: flat tree
{
"root": "card-1",
"elements": {
"card-1": {
"type": "Card",
"props": { "title": "Dashboard" },
"children": ["btn-1"]
},
"btn-1": {
"type": "Button",
"props": { "label": "Save", "variant": "default" }
}
}
}Step 4: Replace Event Handlers
Custom GenUI often passes onClick, onChange as props. json-render uses the on field instead:
// Before: handler in props
{ "type": "button", "props": { "label": "Save", "onClick": "save()" } }
// After: on field with action
{
"type": "Button",
"props": { "label": "Save", "variant": "default" },
"on": { "press": { "action": "submit", "url": "/api/save", "method": "POST" } }
}Step 5: Wrap Existing Component Implementations
Your existing React/Vue components work as json-render implementations with minimal changes:
import type { CatalogComponents } from '@json-render/react'
import type { catalog } from './catalog'
// Reuse existing component implementations
import { Card as ExistingCard } from '@/components/Card'
import { Button as ExistingButton } from '@/components/Button'
export const components: CatalogComponents<typeof catalog> = {
Card: ({ title, description, elevated, children }) => (
<ExistingCard title={title} description={description} elevated={elevated}>
{children}
</ExistingCard>
),
Button: ({ label, variant }) => (
<ExistingButton variant={variant}>{label}</ExistingButton>
),
}Step 6: Update AI Prompts
Update your LLM system prompts to generate flat-tree specs instead of nested JSON:
// Before: "Generate a UI component tree as nested JSON"
// After: "Generate a json-render spec. Use the flat-tree format with root and elements."Include the catalog schema in the system prompt so the AI knows which types and props are available.
Migration Checklist
- Inventoried all existing component types and props
- Created Zod schemas for each component with proper constraints
- Converted nested specs to flat-tree format
- Replaced event handler props with
onfield actions - Wrapped existing component implementations with catalog types
- Updated AI system prompts to generate flat-tree specs
- Added runtime validation via
<Render catalog=\{...\}>component - Tested with existing specs to verify backward compatibility
- Enabled streaming support if using progressive rendering
json-render Package Ecosystem
json-render Package Ecosystem
23 packages under the @json-render scope, organized by category. All packages share the same spec format โ a spec generated for React works with Vue, Svelte, React Native, PDF, and email renderers.
Foundation
| Package | Purpose |
|---|---|
@json-render/core | defineCatalog(), mergeCatalogs(), spec validation, type utilities |
Web Renderers
| Package | Framework | Notes |
|---|---|---|
@json-render/react | React 18/19 | <Render> component, hooks, streaming support |
@json-render/vue | Vue 3 | <Render> component, composables |
@json-render/svelte | Svelte 5 | <Render> component, runes-compatible |
@json-render/solid | SolidJS | <Render> component, fine-grained reactivity |
Component Libraries
| Package | Components | Notes |
|---|---|---|
@json-render/shadcn | 36 | shadcn/ui components with Zod schemas and implementations |
Mobile
| Package | Platform | Notes |
|---|---|---|
@json-render/react-native | iOS / Android | 25+ components, Expo and bare RN support |
Output Renderers
| Package | Output | Notes |
|---|---|---|
@json-render/react-pdf | Generates PDF documents from specs via react-pdf | |
@json-render/react-email | Email HTML | Email-safe HTML from specs via react-email |
@json-render/image | PNG / SVG | Renders specs to images via Satori |
@json-render/remotion | Video | Animated specs rendered as video via Remotion |
@json-render/yaml | YAML specs | Parse/stringify YAML format specs (30% fewer tokens) |
3D
| Package | Purpose | Notes |
|---|---|---|
@json-render/react-three-fiber | 3D scenes | WebGL rendering via React Three Fiber |
MCP (Model Context Protocol)
| Package | Purpose | Notes |
|---|---|---|
@json-render/mcp | MCP tool output | Render specs as MCP tool results for AI agents |
Code Generation
| Package | Purpose | Notes |
|---|---|---|
@json-render/codegen | JSX/TSX output | Convert specs to static React/Vue/Svelte component code |
State Adapters
| Package | Library | Notes |
|---|---|---|
@json-render/redux | Redux Toolkit | Bidirectional state sync with Redux store |
@json-render/zustand | Zustand | Adapter for Zustand stores |
@json-render/jotai | Jotai | Atom-based state adapter |
@json-render/xstate | XState 5 | State machine adapter for complex workflows |
Installation Patterns
Minimal (React + custom catalog):
npm install @json-render/core @json-render/react zodWith shadcn components:
npm install @json-render/core @json-render/react @json-render/shadcn zodCross-platform (web + mobile + PDF):
npm install @json-render/core @json-render/react @json-render/react-native @json-render/react-pdf zodWith YAML optimization:
npm install @json-render/core @json-render/react @json-render/yaml zodWith state management (Zustand example):
npm install @json-render/core @json-render/react @json-render/zustand zod zustandWrite Once, Render Anywhere
The key value proposition: a single spec works across all renderers. Generate a dashboard spec once, render it as:
- Interactive web UI (
@json-render/react) - Mobile app (
@json-render/react-native) - PDF report (
@json-render/react-pdf) - Email digest (
@json-render/react-email) - Static image (
@json-render/image)
The catalog + spec is the shared contract. Each renderer maps catalog types to platform-specific implementations.
json-render Spec Format
json-render Spec Format
The JSON spec is the data contract between AI and the renderer. It uses a flat-tree structure where all elements are top-level entries referenced by ID โ no nesting.
Structure
{
"root": "<element-id>",
"elements": {
"<element-id>": {
"type": "<CatalogComponentName>",
"props": { ... },
"children": ["<child-id-1>", "<child-id-2>"],
"on": { "<event>": { "action": "...", ... } },
"watch": { "<state-path>": { "action": "...", ... } }
}
},
"state": {
"/<path>": <value>
}
}Fields
root (required)
The ID of the top-level element to render. Must reference a key in elements.
{ "root": "page-container" }elements (required)
A flat map of element ID to element definition. Every element in the spec lives here โ no nesting.
Element Fields
type (required): Must match a component name in the catalog. Runtime validation rejects unknown types.
props (required): Object matching the Zod schema defined in the catalog for this type. Validated at render time.
children (optional): Array of element IDs that are children of this element. Only valid if the catalog entry allows children (children: true or a typed array like ['Button']).
"children": ["heading-1", "content-1", "footer-btn"]on (optional): Event handlers keyed by event name.
"on": {
"press": { "action": "setState", "path": "/view", "value": "detail" },
"change": { "action": "setState", "path": "/search", "value": "$event" }
}The $event variable refers to the event value (input text, checkbox state, etc.).
watch (optional): Reactive bindings keyed by state path. When the watched path changes, the action fires.
"watch": {
"/filters": {
"action": "load_data",
"url": "/api/items",
"params": { "$ref": "/filters" }
}
}state (optional)
Top-level state tree using JSON Pointer paths. All setState and watch references point to paths in this tree.
"state": {
"/activeTab": "overview",
"/selectedIds": [],
"/filters": { "status": "all" },
"/data": null
}Flat-Tree Design
json-render uses a flat tree (all elements as siblings) rather than nested JSON because:
- Streaming โ Elements can be added independently via JSON Patch without re-sending parent context
- Reuse โ The same element ID can be referenced as a child of multiple parents
- Simplicity โ AI generates a flat list, not deeply nested structures that are harder to validate
- Patching โ Updating a single element requires one patch operation, not a deep path traversal
Complete Example
{
"root": "dashboard",
"elements": {
"dashboard": {
"type": "Card",
"props": { "title": "Sales Dashboard" },
"children": ["stats", "tabs"]
},
"stats": {
"type": "StatGrid",
"props": {
"items": [
{ "label": "Revenue", "value": "$42K", "trend": "up" },
{ "label": "Orders", "value": "1,234", "trend": "up" },
{ "label": "Refunds", "value": "23", "trend": "down" }
]
}
},
"tabs": {
"type": "Tabs",
"props": { "defaultValue": "chart" },
"children": ["chart-tab", "table-tab"]
},
"chart-tab": {
"type": "Card",
"props": { "title": "Revenue Chart" },
"children": []
},
"table-tab": {
"type": "Table",
"props": {
"columns": [
{ "key": "date", "label": "Date", "sortable": true },
{ "key": "amount", "label": "Amount", "sortable": true },
{ "key": "status", "label": "Status", "sortable": false }
],
"rows": []
},
"watch": {
"/data/orders": {
"action": "load_data",
"url": "/api/orders"
}
}
}
},
"state": {
"/data/orders": null
}
}Validation Flow
rootmust reference an existing element ID- Each element
typemust exist in the catalog - Each element
propsmust pass the Zod schema for that type - Each
childrenentry must reference an existing element ID - Children are only allowed if the catalog entry permits them
onevent names must be valid for the component typewatchpaths must reference paths in thestatetree
Issue Progress Tracking
Auto-updates GitHub issues with commit progress. Use when starting work on an issue, tracking progress during implementation, or completing work with a PR.
Langgraph
LangGraph 1.x (LTS) workflow patterns for state management, routing, parallel execution, supervisor-worker, tool calling, checkpointing, human-in-loop, streaming (v2 format), subgraphs, and functional API. Use when building LangGraph pipelines, multi-agent systems, or AI workflows.
Last updated on