Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Generative UI framework that renders AI-generated JSON specs into type-safe UI components across React, Vue, Svelte, Solid, React Native, video, PDF, and email.
.claude/skills/json-render-generative-ui/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-13 | ✗→✓ | ▲ Improved | — | — |
| case-10 | ✗→✓ | ▲ Improved | — | — |
| case-05 | ✗→✓ | ▲ Improved | — | — |
| case-09 | ✗→✓ | ▲ Improved | — | — |
| case-16 | ✗→✓ | ▲ Improved | — | — |
> Skill by ara.so — Daily 2026 Skills collection.
json-render is a Generative UI framework that lets AI generate dynamic interfaces from natural language prompts, constrained to a predefined component catalog. AI outputs JSON; json-render renders it safely and predictably across any platform.
bash# React (core) npm install @json-render/core @json-render/react # React + shadcn/ui (36 pre-built components) npm install @json-render/shadcn # React Native npm install @json-render/core @json-render/react-native # Vue npm install @json-render/core @json-render/vue # Svelte npm install @json-render/core @json-render/svelte # SolidJS npm install @json-render/core @json-render/solid # Video (Remotion) npm install @json-render/core @json-render/remotion # PDF npm install @json-render/core @json-render/react-pdf # Email npm install @json-render/core @json-render/react-email @react-email/components @react-email/render # 3D (React Three Fiber) npm install @json-render/core @json-render/react-three-fiber @react-three/fiber @react-three/drei three # OG Images / SVG / PNG npm install @json-render/core @json-render/image # State management adapters npm install @json-render/zustand # or redux, jotai, xstate # MCP integration (Claude, ChatGPT, Cursor) npm install @json-render/mcp # YAML wire format npm install @json-render/yaml
| Concept | Description | |---|---| | Catalog | Defines allowed components and actions (the guardrails for AI) | | Spec | AI-generated JSON describing which components to render and with what props | | Registry | Maps catalog component names to actual render implementations | | Renderer | Platform-specific component that takes a spec + registry and renders UI | | Actions | Named events AI can trigger (e.g. export_report, refresh_data) |
The flat spec format uses a root key + elements map:
typescriptconst spec = { root: "card-1", elements: { "card-1": { type: "Card", props: { title: "Dashboard" }, children: ["metric-1", "metric-2", "button-1"], }, "metric-1": { type: "Metric", props: { label: "Revenue", value: "124000", format: "currency" }, children: [], }, "metric-2": { type: "Metric", props: { label: "Growth", value: "0.18", format: "percent" }, children: [], }, "button-1": { type: "Button", props: { label: "Export Report", action: "export_report" }, children: [], }, }, };
typescriptimport { defineCatalog } from "@json-render/core"; import { schema } from "@json-render/react/schema"; import { z } from "zod"; const catalog = defineCatalog(schema, { components: { Card: { props: z.object({ title: z.string() }), description: "A card container with a title", }, Metric: { props: z.object({ label: z.string(), value: z.string(), format: z.enum(["currency", "percent", "number"]).nullable(), }), description: "Displays a single metric value with optional formatting", }, Button: { props: z.object({ label: z.string(), action: z.string(), }), description: "Clickable button that triggers an action", }, Stack: { props: z.object({ direction: z.enum(["row", "column"]).default("column"), gap: z.number().optional(), }), description: "Layout container that stacks children", }, }, actions: { export_report: { description: "Export the current dashboard to PDF" }, refresh_data: { description: "Refresh all metric data" }, navigate: { description: "Navigate to a page", payload: z.object({ path: z.string() }), }, }, });
tsximport { defineRegistry, Renderer } from "@json-render/react"; function format(value: string, fmt: string | null): string { if (fmt === "currency") return `$${Number(value).toLocaleString()}`; if (fmt === "percent") return `${(Number(value) * 100).toFixed(1)}%`; return value; } const { registry } = defineRegistry(catalog, { components: { Card: ({ props, children }) => ( <div className="rounded-lg border p-4 shadow-sm"> <h3 className="text-lg font-semibold mb-3">{props.title}</h3> {children} </div> ), Metric: ({ props }) => ( <div className="flex flex-col"> <span className="text-sm text-gray-500">{props.label}</span> <span className="text-2xl font-bold"> {format(props.value, props.format)} </span> </div> ), Button: ({ props, emit }) => ( <button className="px-4 py-2 bg-blue-600 text-white rounded" onClick={() => emit("press")} > {props.label} </button> ), Stack: ({ props, children }) => ( <div style={{ display: "flex", flexDirection: props.direction ?? "column", gap: props.gap ?? 8, }} > {children} </div> ), }, });
tsximport { Renderer } from "@json-render/react"; function Dashboard({ spec, onAction }) { return ( <Renderer spec={spec} registry={registry} onAction={(action, payload) => { console.log("Action triggered:", action, payload); onAction?.(action, payload); }} /> ); }
typescriptimport { generateObject } from "ai"; import { openai } from "@ai-sdk/openai"; import { getCatalogSchema, getCatalogPrompt } from "@json-render/core"; async function generateDashboard(userPrompt: string) { const { object: spec } = await generateObject({ model: openai("gpt-4o"), schema: getCatalogSchema(catalog), system: getCatalogPrompt(catalog), prompt: userPrompt, }); return spec; } // Usage const spec = await generateDashboard( "Create a sales dashboard showing revenue, conversion rate, and an export button" );
tsximport { streamObject } from "ai"; import { openai } from "@ai-sdk/openai"; import { getCatalogSchema, getCatalogPrompt, parseSpecStream } from "@json-render/core"; import { Renderer } from "@json-render/react"; import { useState, useEffect } from "react"; function StreamingDashboard({ prompt }: { prompt: string }) { const [spec, setSpec] = useState(null); useEffect(() => { async function stream() { const { partialObjectStream } = await streamObject({ model: openai("gpt-4o"), schema: getCatalogSchema(catalog), system: getCatalogPrompt(catalog), prompt, }); for await (const partial of partialObjectStream) { setSpec(partial); // Renderer handles partial specs gracefully } } stream(); }, [prompt]); if (!spec) return <div>Generating UI...</div>; return <Renderer spec={spec} registry={registry} />; }
tsximport { defineCatalog } from "@json-render/core"; import { schema } from "@json-render/react/schema"; import { defineRegistry, Renderer } from "@json-render/react"; import { shadcnComponentDefinitions } from "@json-render/shadcn/catalog"; import { shadcnComponents } from "@json-render/shadcn"; // Pick any of the 36 available shadcn components const catalog = defineCatalog(schema, { components: { Card: shadcnComponentDefinitions.Card, Stack: shadcnComponentDefinitions.Stack, Heading: shadcnComponentDefinitions.Heading, Text: shadcnComponentDefinitions.Text, Button: shadcnComponentDefinitions.Button, Badge: shadcnComponentDefinitions.Badge, Table: shadcnComponentDefinitions.Table, Chart: shadcnComponentDefinitions.Chart, Input: shadcnComponentDefinitions.Input, Select: shadcnComponentDefinitions.Select, }, actions: { submit: { description: "Submit a form" }, export: { description: "Export data" }, }, }); const { registry } = defineRegistry(catalog, { components: { Card: shadcnComponents.Card, Stack: shadcnComponents.Stack, Heading: shadcnComponents.Heading, Text: shadcnComponents.Text, Button: shadcnComponents.Button, Badge: shadcnComponents.Badge, Table: shadcnComponents.Table, Chart: shadcnComponents.Chart, Input: shadcnComponents.Input, Select: shadcnComponents.Select, }, }); function AIPage({ spec }) { return <Renderer spec={spec} registry={registry} />; }
typescriptimport { h, defineComponent } from "vue"; import { defineCatalog } from "@json-render/core"; import { schema } from "@json-render/vue/schema"; import { defineRegistry, Renderer } from "@json-render/vue"; import { z } from "zod"; const catalog = defineCatalog(schema, { components: { Card: { props: z.object({ title: z.string() }), description: "Card container", }, Button: { props: z.object({ label: z.string() }), description: "Button", }, }, actions: { click: { description: "Button clicked" }, }, }); const { registry } = defineRegistry(catalog, { components: { Card: ({ props, children }) => h("div", { class: "card" }, [ h("h3", null, props.title), children, ]), Button: ({ props, emit }) => h("button", { onClick: () => emit("click") }, props.label), }, }); // In your Vue SFC: // <template> // <Renderer :spec="spec" :registry="registry" /> // </template>
tsximport { defineCatalog } from "@json-render/core"; import { schema } from "@json-render/react-native/schema"; import { standardComponentDefinitions, standardActionDefinitions, } from "@json-render/react-native/catalog"; import { defineRegistry, Renderer } from "@json-render/react-native"; // 25+ standard mobile components out of the box const catalog = defineCatalog(schema, { components: { ...standardComponentDefinitions }, actions: standardActionDefinitions, }); const { registry } = defineRegistry(catalog, { components: {}, // use all standard implementations }); export function AIScreen({ spec }) { return <Renderer spec={spec} registry={registry} />; }
typescriptimport { renderToBuffer } from "@json-render/react-pdf"; const invoiceSpec = { root: "doc", elements: { doc: { type: "Document", props: { title: "Invoice #1234" }, children: ["page-1"], }, "page-1": { type: "Page", props: { size: "A4" }, children: ["heading-1", "table-1"], }, "heading-1": { type: "Heading", props: { text: "Invoice #1234", level: "h1" }, children: [], }, "table-1": { type: "Table", props: { columns: [ { header: "Item", width: "60%" }, { header: "Amount", width: "40%", align: "right" }, ], rows: [ ["Widget A", "$10.00"], ["Widget B", "$25.00"], ["Total", "$35.00"], ], }, children: [], }, }, }; // Returns a Buffer you can send as a response const buffer = await renderToBuffer(invoiceSpec); // In a Next.js route handler: export async function GET() { const buffer = await renderToBuffer(invoiceSpec); return new Response(buffer, { headers: { "Content-Type": "application/pdf" }, }); }
typescriptimport { renderToHtml } from "@json-render/react-email"; import { schema, standardComponentDefinitions } from "@json-render/react-email"; import { defineCatalog } from "@json-render/core"; const catalog = defineCatalog(schema, { components: standardComponentDefinitions, }); const emailSpec = { root: "html-1", elements: { "html-1": { type: "Html", props: { lang: "en" }, children: ["head-1", "body-1"], }, "head-1": { type: "Head", props: {}, children: [] }, "body-1": { type: "Body", props: { style: { backgroundColor: "#f6f9fc" } }, children: ["container-1"], }, "container-1": { type: "Container", props: { style: { maxWidth: "600px", margin: "0 auto" } }, children: ["heading-1", "text-1", "button-1"], }, "heading-1": { type: "Heading", props: { text: "Welcome aboard!" }, children: [], }, "text-1": { type: "Text", props: { text: "Thanks for signing up. Click below to get started." }, children: [], }, "button-1": { type: "Button", props: { text: "Get Started", href: "https://example.com" }, children: [], }, }, }; const html = await renderToHtml(emailSpec);
typescriptimport { createMCPServer } from "@json-render/mcp"; const server = createMCPServer({ catalog, name: "my-ui-server", version: "1.0.0", }); server.start();
typescriptimport { create } from "zustand"; import { createZustandAdapter } from "@json-render/zustand"; const useStore = create((set) => ({ data: {}, setData: (data) => set({ data }), })); const stateStore = createZustandAdapter(useStore); // Pass to Renderer for action handling with state <Renderer spec={spec} registry={registry} stateStore={stateStore} />;
typescriptimport { parseYAML, toYAML } from "@json-render/yaml"; // AI can output YAML instead of JSON (often more token-efficient) const yamlSpec = ` root: card-1 elements: card-1: type: Card props: title: Hello World children: [button-1] button-1: type: Button props: label: Click Me children: [] `; const spec = parseYAML(yamlSpec);
tsx// app/dashboard/page.tsx import { generateObject } from "ai"; import { openai } from "@ai-sdk/openai"; import { getCatalogSchema, getCatalogPrompt } from "@json-render/core"; import { DashboardRenderer } from "./DashboardRenderer"; import { catalog } from "@/lib/catalog"; export default async function DashboardPage({ searchParams, }: { searchParams: { q?: string }; }) { const prompt = searchParams.q ?? "Show me a sales overview dashboard"; const { object: spec } = await generateObject({ model: openai("gpt-4o"), schema: getCatalogSchema(catalog), system: getCatalogPrompt(catalog), prompt, }); return <DashboardRenderer spec={spec} />; }
tsx// app/dashboard/DashboardRenderer.tsx "use client"; import { Renderer } from "@json-render/react"; import { registry } from "@/lib/registry"; import { useRouter } from "next/navigation"; export function DashboardRenderer({ spec }) { const router = useRouter(); return ( <Renderer spec={spec} registry={registry} onAction={(action, payload) => { switch (action) { case "navigate": router.push(payload.path); break; case "export_report": window.open("/api/export", "_blank"); break; case "refresh_data": router.refresh(); break; } }} /> ); }
typescript// Restrict catalog based on user role function getCatalogForRole(role: "admin" | "viewer") { const base = { Card, Stack, Heading, Text, Metric }; const adminOnly = role === "admin" ? { Button, Form, Table } : {}; const adminActions = role === "admin" ? { export: { description: "Export data" } } : {}; return defineCatalog(schema, { components: { ...base, ...adminOnly }, actions: adminOnly ? adminActions : {}, }); }
tsx// Components can fetch their own data const { registry } = defineRegistry(catalog, { components: { LiveMetric: ({ props }) => { const { data } = useSWR(`/api/metrics/${props.metricId}`); return ( <div> <span>{props.label}</span> <span>{data?.value ?? "..."}</span> </div> ); }, }, });
typescriptimport { type ActionHandler } from "@json-render/core"; const handleAction: ActionHandler<typeof catalog> = (action, payload) => { // action and payload are fully typed based on your catalog definition if (action === "navigate") { router.push(payload.path); // payload.path is typed as string } };
| Problem | Cause | Fix | |---|---|---| | AI generates unknown component type | Component not in catalog | Add component to defineCatalog or update AI prompt | | Props validation error | AI hallucinated a prop | Tighten Zod schema, add .strict() or .describe() hints | | Renderer shows nothing | root key doesn't match an elements key | Check spec structure; root must reference a valid element ID | | Partial spec renders incorrectly | Streaming not handled | Use parseSpecStream utility or check for null elements before render | | Actions not firing | onAction not passed to Renderer | Pass onAction prop to <Renderer> | | shadcn components unstyled | Missing Tailwind config | Ensure @json-render/shadcn paths are in tailwind.config.js content array | | TypeScript errors in registry | Catalog/registry mismatch | Ensure defineRegistry(catalog, ...) uses the same catalog instance |
bash# For AI generation (use your preferred provider) OPENAI_API_KEY=your_key_here ANTHROPIC_API_KEY=your_key_here # For MCP server MCP_SERVER_PORT=3001
typescript// Core defineCatalog(schema, { components, actions }) // Define guardrails getCatalogSchema(catalog) // Get Zod schema for AI getCatalogPrompt(catalog) // Get system prompt for AI // React defineRegistry(catalog, { components }) // Create typed registry <Renderer spec={spec} registry={registry} onAction={fn} /> // Core utilities parseSpecStream(stream) // Parse streaming partial specs toYAML(spec) // Convert spec to YAML parseYAML(yaml) // Parse YAML spec to JSON // PDF renderToBuffer(spec) // → Buffer renderToStream(spec) // → ReadableStream // Email renderToHtml(spec) // → HTML string renderToText(spec) // → plain text string
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-13 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-10 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-05 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-09 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-16 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-06 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-12 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-04 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-08 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-14 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-03 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-01 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-19 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-07 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-02 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-21 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-18 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-22 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-11 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-15 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-17 | pass→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-20 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-23 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 23 cases were attempted. The headline lift of +74 percentage points is the difference between those two pass rates over the 23 comparable cases.
The per-case answers from this run were removed by the retention sweep, so the case table below shows the verdicts without the text either arm produced. The counts above were recorded at the time and are unaffected. Answers are now kept for 180 days.
Other measured skills in the registry, with their headline benchmark lift.