Skip to main content
OrchestKit v7.22.0 โ€” 98 skills, 35 agents, 106 hooks ยท Claude Code 2.1.76+
OrchestKit
Skills

Json Render Catalog

>-

Reference medium

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

CategoryRulesImpactWhen to Use
Catalog Definition1HIGHDefining component catalogs with Zod
Prop Constraints1HIGHConstraining AI-generated props for safety
shadcn Catalog1MEDIUMUsing pre-built shadcn components
Token Optimization1MEDIUMReducing token usage with YAML mode
Actions & State1MEDIUMAdding interactivity to specs

Total: 5 rules across 5 categories

How json-render Works

  1. Developer defines a catalog โ€” Zod-typed component definitions with constrained props
  2. AI generates a spec โ€” flat-tree JSON/YAML referencing only catalog components
  3. 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: default

Use 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.

RuleFileKey Pattern
Catalog Definitionrules/catalog-definition.mddefineCatalog with Zod schemas, children types

Prop Constraints

Constraining props to prevent AI hallucination.

RuleFileKey Pattern
Prop Constraintsrules/prop-constraints.mdz.enum, z.string().max(), z.array().max()

shadcn Catalog

Using the 36 pre-built shadcn components.

RuleFileKey Pattern
shadcn Catalogrules/shadcn-catalog.md@json-render/shadcn components and extension

Token Optimization

Choosing JSON vs YAML for token efficiency.

RuleFileKey Pattern
Token Optimizationrules/token-optimization.mdYAML for one-shot, JSON for streaming

Actions & State

Adding interactivity with events, watchers, and state.

RuleFileKey Pattern
Action & Staterules/action-state.mdon events, watch reactivity, state adapters

Key Decisions

DecisionRecommendation
Custom vs shadcn catalogStart with shadcn, extend with custom types for domain-specific components
JSON vs YAML spec formatYAML for one-shot (30% fewer tokens), JSON for streaming
Zod constraint strictnessTighter is better โ€” use z.enum over z.string, z.array().max() over unbounded
State management adapterMatch your app's existing state library (Zustand, Redux, Jotai, XState)

Common Mistakes

  1. Using z.any() or z.unknown() in catalog props โ€” defeats the purpose of catalog constraints, AI can generate anything
  2. Always using JSON specs โ€” wastes 30% tokens when streaming is not needed
  3. Nesting component definitions โ€” json-render uses a flat tree; all elements are siblings referenced by ID
  4. Skipping mergeCatalogs() when combining shadcn + custom โ€” manual merging loses type safety
  5. Not setting .max() on arrays โ€” AI can generate unbounded lists that break layouts
  • ork:ai-ui-generation โ€” AI-assisted UI generation patterns for v0, Bolt, Cursor
  • ork:ui-components โ€” shadcn/ui component patterns and CVA variants
  • ork:component-search โ€” Finding and evaluating React/Vue components
  • ork: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.

EventTriggers When
pressButton click / tap
changeInput, Select, Switch, Checkbox value change
submitForm submission
focus / blurElement 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

ActionDescriptionExample
setStateSet a state path to a value\{ "action": "setState", "path": "/tab", "value": "settings" \}
load_dataFetch data from URL into state\{ "action": "load_data", "url": "/api/data" \}
submitPOST/PUT data to URL\{ "action": "submit", "url": "/api/save", "method": "POST" \}
navigateClient-side navigation\{ "action": "navigate", "to": "/dashboard" \}
toggleToggle 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 on for user-initiated actions (clicks, input changes) โ€” never for data loading
  • Use watch for reactive data fetching โ€” it re-fetches when the watched state path changes
  • Define all state paths in the top-level state object โ€” referencing undefined paths is a runtime error
  • Use $ref in 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

ValueMeaningUse For
trueAccepts any catalog childrenLayout components (Card, Section, Grid)
falseLeaf node, no childrenData display (StatGrid, Chart, Badge)
['Button', 'Badge']Only accepts specific types as childrenConstrained 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 props Zod schema and a children declaration
  • 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 TypeWeak (avoid)Strong (use)
Text contentz.string()z.string().min(1).max(200)
Color/variantz.string()z.enum(['primary', 'secondary'])
List itemsz.array(z.any())z.array(schema).min(1).max(20)
Numericz.number()z.number().int().min(0).max(100)
Boolean flags(no constraint)z.boolean().default(false)
Freeform objectz.record(z.any())z.object(\{ specific: z.string() \})
URL/imagez.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 bare z.record() in catalog props โ€” these bypass AI safety
  • Always set .max() on strings and arrays to prevent unbounded generation
  • Use z.enum() instead of z.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:

ComponentKey PropsChildren
Cardtitle, description, footertrue
Accordiontype (single/multiple), collapsibletrue
TabsdefaultValue, orientationtrue
Sheetside (top/right/bottom/left)true
Dialogtitle, descriptiontrue
Collapsibleopen, defaultOpentrue
Separatororientation, decorativefalse
ScrollAreaorientationtrue
AspectRatioratiotrue

Data Display:

ComponentKey PropsChildren
Tablecolumns, rows, captionfalse
Badgevariant, labelfalse
Avatarsrc, alt, fallbackfalse
Progressvalue, maxfalse
Skeletonwidth, height, variantfalse
HoverCardtriggerTexttrue
Tooltipcontent, sidetrue

Form & Input:

ComponentKey PropsChildren
Buttonlabel, variant, size, disabledfalse
Inputplaceholder, type, labelfalse
Textareaplaceholder, rows, labelfalse
Selectoptions, placeholder, labelfalse
Checkboxlabel, checked, disabledfalse
RadioGroupoptions, defaultValue, labelfalse
Switchlabel, checked, disabledfalse
Slidermin, max, step, defaultValuefalse
Labeltext, htmlForfalse
Togglelabel, pressed, variantfalse
ToggleGrouptype, itemsfalse

Navigation:

ComponentKey PropsChildren
NavigationMenuitemsfalse
Breadcrumbitems, separatorfalse
Menubarmenusfalse
DropdownMenutriggerLabel, itemsfalse
ContextMenuitemstrue
Commandplaceholder, groupsfalse

Feedback:

ComponentKey PropsChildren
Alerttitle, description, variantfalse
AlertDialogtitle, description, actionLabelfalse
Toasttitle, description, variant, actionfalse

When to Extend vs Use As-Is

ScenarioApproach
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 overhead

Correct:

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

CriterionJSONYAML
Streaming (progressive render)RequiredNot supported
One-shot generationWorks but wasteful30% fewer tokens
Token cost sensitivityHigherLower
Parsing reliabilityNative JSON.parseRequires yaml parser
AI familiarityHigher (more training data)High (common in configs)
Spec debuggingEasy (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 โ†’ YAML

Token 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 parseYamlSpec from @json-render/yaml to 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 on field actions
  • Wrapped existing component implementations with catalog types
  • Updated AI system prompts to generate flat-tree specs
  • Added runtime validation via &lt;Render catalog=\{...\}&gt; 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

PackagePurpose
@json-render/coredefineCatalog(), mergeCatalogs(), spec validation, type utilities

Web Renderers

PackageFrameworkNotes
@json-render/reactReact 18/19&lt;Render&gt; component, hooks, streaming support
@json-render/vueVue 3&lt;Render&gt; component, composables
@json-render/svelteSvelte 5&lt;Render&gt; component, runes-compatible
@json-render/solidSolidJS&lt;Render&gt; component, fine-grained reactivity

Component Libraries

PackageComponentsNotes
@json-render/shadcn36shadcn/ui components with Zod schemas and implementations

Mobile

PackagePlatformNotes
@json-render/react-nativeiOS / Android25+ components, Expo and bare RN support

Output Renderers

PackageOutputNotes
@json-render/react-pdfPDFGenerates PDF documents from specs via react-pdf
@json-render/react-emailEmail HTMLEmail-safe HTML from specs via react-email
@json-render/imagePNG / SVGRenders specs to images via Satori
@json-render/remotionVideoAnimated specs rendered as video via Remotion
@json-render/yamlYAML specsParse/stringify YAML format specs (30% fewer tokens)

3D

PackagePurposeNotes
@json-render/react-three-fiber3D scenesWebGL rendering via React Three Fiber

MCP (Model Context Protocol)

PackagePurposeNotes
@json-render/mcpMCP tool outputRender specs as MCP tool results for AI agents

Code Generation

PackagePurposeNotes
@json-render/codegenJSX/TSX outputConvert specs to static React/Vue/Svelte component code

State Adapters

PackageLibraryNotes
@json-render/reduxRedux ToolkitBidirectional state sync with Redux store
@json-render/zustandZustandAdapter for Zustand stores
@json-render/jotaiJotaiAtom-based state adapter
@json-render/xstateXState 5State machine adapter for complex workflows

Installation Patterns

Minimal (React + custom catalog):

npm install @json-render/core @json-render/react zod

With shadcn components:

npm install @json-render/core @json-render/react @json-render/shadcn zod

Cross-platform (web + mobile + PDF):

npm install @json-render/core @json-render/react @json-render/react-native @json-render/react-pdf zod

With YAML optimization:

npm install @json-render/core @json-render/react @json-render/yaml zod

With state management (Zustand example):

npm install @json-render/core @json-render/react @json-render/zustand zod zustand

Write 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:

  1. Streaming โ€” Elements can be added independently via JSON Patch without re-sending parent context
  2. Reuse โ€” The same element ID can be referenced as a child of multiple parents
  3. Simplicity โ€” AI generates a flat list, not deeply nested structures that are harder to validate
  4. 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

  1. root must reference an existing element ID
  2. Each element type must exist in the catalog
  3. Each element props must pass the Zod schema for that type
  4. Each children entry must reference an existing element ID
  5. Children are only allowed if the catalog entry permits them
  6. on event names must be valid for the component type
  7. watch paths must reference paths in the state tree
Edit on GitHub

Last updated on

On this page

json-render Component CatalogsQuick ReferenceHow json-render WorksQuick Start โ€” 3 StepsStep 1: Define a CatalogStep 2: Implement ComponentsStep 3: Render a SpecSpec FormatWith Interactivity (on / watch / state)YAML Mode โ€” 30% Fewer TokensProgressive Streaming@json-render/shadcn โ€” 36 Pre-Built ComponentsPackage EcosystemWhen to Use vs When NOT to UseMigrating from Custom GenUIRule DetailsCatalog DefinitionProp Constraintsshadcn CatalogToken OptimizationActions & StateKey DecisionsCommon MistakesRelated SkillsRules (5)Actions and State Bindings in Specs โ€” MEDIUMActions and State Bindings in SpecsThe Three PrimitivesBuilt-in ActionsState AdaptersCatalog Definition with defineCatalog and Zod โ€” HIGHCatalog Definition with defineCatalog and ZodChildren TypesMerging CatalogsProp Constraints for AI Safety โ€” HIGHProp Constraints for AI SafetyConstraint Patterns by Prop TypeRefinements for Complex Validationshadcn Catalog โ€” 36 Pre-Built Components โ€” MEDIUMshadcn Catalog โ€” 36 Pre-Built ComponentsThe 36 shadcn ComponentsWhen to Extend vs Use As-IsToken Optimization โ€” YAML Mode โ€” MEDIUMToken Optimization โ€” YAML ModeFormat Selection CriteriaDecision RuleToken Comparison ExampleReferences (3)Migrating from Custom GenUI to json-renderMigrating from Custom GenUI to json-renderCommon Custom GenUI PatternsPattern A: Direct Type MappingPattern B: Switch StatementPattern C: Nested JSONMigration StepsStep 1: Inventory Existing ComponentsStep 2: Define Zod Schemas for Each ComponentStep 3: Flatten Nested SpecsStep 4: Replace Event HandlersStep 5: Wrap Existing Component ImplementationsStep 6: Update AI PromptsMigration Checklistjson-render Package Ecosystemjson-render Package EcosystemFoundationWeb RenderersComponent LibrariesMobileOutput Renderers3DMCP (Model Context Protocol)Code GenerationState AdaptersInstallation PatternsWrite Once, Render Anywherejson-render Spec Formatjson-render Spec FormatStructureFieldsroot (required)elements (required)Element Fieldsstate (optional)Flat-Tree DesignComplete ExampleValidation Flow