Angular renderer for json-render. It renders a JSON spec, generated by an AI model or built by your server, using your own Angular components.
Docs & live demo · GitHub · Example app (skein-arcade)
json-render is a framework for generative UI: an AI model generates the interface (which components to show, how to arrange them, what data to bind and which actions to wire up), but only from components and actions you define. The model outputs JSON, not code. Your app validates that JSON and renders it with its own components. Nothing in the spec is executed.
The same approach works for server-driven UI, where your backend builds the spec instead of a model.
ng-json-render is the Angular renderer. The catalog, spec format, prompt generation and streaming come from @json-render/core, so the upstream json-render docs apply. This package adds the Angular rendering layer.
| Concept | Description | In ng-json-render |
|---|---|---|
| Catalog | The components and actions the model may use, with Zod-typed props and descriptions. It is the contract between your app and the model. | defineCatalog(schema, {...}), usually on the server |
| Spec | The JSON the model generates: a flat map of elements, each with a type, props and children. |
The Spec type |
| Registry | Maps each catalog type to the component that renders it. | defineRegistry({ Card: MyCard }) |
| Renderer | Renders a spec using a registry. | <jr-renderer> |
- You define a catalog.
catalog.prompt()turns it into a system prompt. - The model responds with a spec, streamed as JSON patches.
<jr-renderer>renders each element with the component registered for itstype. Types not in the registry are skipped.- Components raise actions. The spec only names them; your handlers decide what runs.
| Package | Description |
|---|---|
@ng-json-render/core |
The renderer (JrRenderer), defineRegistry, mergeRegistries, provideJsonRender and JR_CONTEXT. Re-exports defineSchema, defineCatalog, validateSpec, createSpecStreamCompiler and compileSpecStream from @json-render/core. |
@ng-json-render/core/testing |
renderSpec(), a TestBed harness. |
@ng-json-render/primitives |
20 Tailwind-styled components and primitivesRegistry. It does not include a catalog. |
npm i @ng-json-render/core @json-render/core
npm i @ng-json-render/primitives # optional: built-in components
npm i zod # where you define a catalog (usually the server)Requires Angular 19–22.
@ng-json-render/primitives is styled with Tailwind CSS v4. Set up Tailwind, then add the package to Tailwind's sources so its classes are generated:
/* src/styles.css */
@import 'tailwindcss';
@source '../node_modules/@ng-json-render/primitives';Adjust the path if your stylesheet is not in src/.
provideJsonRender sets the default registry and action handlers for every <jr-renderer> in the app. You can also pass [registry] to a single renderer.
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideJsonRender } from '@ng-json-render/core';
import { primitivesRegistry } from '@ng-json-render/primitives';
export const appConfig: ApplicationConfig = {
providers: [
provideJsonRender({
registry: primitivesRegistry,
actions: {
save: (ctx) => console.log('save', ctx.payload),
},
}),
],
};import { Component, signal } from '@angular/core';
import { JrRenderer, type JrActionEvent, type Spec } from '@ng-json-render/core';
@Component({
selector: 'app-root',
imports: [JrRenderer],
template: `<jr-renderer [spec]="spec()" (action)="onAction($event)" />`,
})
export class App {
spec = signal<Spec>({
root: 'root',
state: { name: 'Ada' },
elements: {
root: { type: 'Stack', props: { gap: 16 }, children: ['title', 'field', 'save'] },
title: { type: 'Heading', props: { value: 'Hello', level: 1 } },
field: { type: 'Input', props: { label: 'Name', value: { $bindState: '/name' } } },
save: { type: 'Button', props: { label: 'Save' }, on: { press: { action: 'save' } } },
},
});
onAction(e: JrActionEvent) {
console.log(e.action, e.nodeId);
}
}<jr-renderer> API:
| Member | Description |
|---|---|
spec input |
The spec to render. Setting a new spec object rebuilds the tree. |
registry input |
Overrides the registry from provideJsonRender. With neither, nothing renders and a warning is logged. |
state input |
Merged over spec.state. |
action output |
Emits { action, payload, nodeId, element, spec } for every action. |
A spec has a root key, an elements map and optional state:
{
"root": "card",
"state": { "user": { "name": "Ada" }, "isAdmin": false },
"elements": {
"card": { "type": "Card", "props": { "title": "Profile" }, "children": ["greeting", "admin"] },
"greeting": { "type": "Text", "props": { "value": { "$template": "Hello, ${/user/name}" } }, "children": [] },
"admin": { "type": "Badge", "props": { "label": "Admin" }, "visible": { "$state": "/isAdmin" }, "children": [] }
}
}root: the key of the first element.elements: a flat map. Elements refer to their children by key instead of nesting them, which lets a model stream them one at a time.type: must exist in the registry.props: set on the component's inputs. Values can be literals or expressions:
| Expression | Effect |
|---|---|
{ "$state": "/path" } |
Reads a value from state. |
{ "$bindState": "/path" } |
Reads a value and writes changes back (two-way binding; see Form controls). |
{ "$template": "Hi ${/user/name}" } |
Interpolates state into a string. |
{ "$cond": <condition>, "$then": a, "$else": b } |
Picks a value based on a condition. |
on: maps a component event to an action, e.g.{ "press": { "action": "save" } }.visible: a condition such as{ "$state": "/isAdmin" },{ "$state": "/status", "eq": "active" },$and,$orornot. It is evaluated when the tree is built. It is not re-evaluated when only state changes.
State paths are JSON Pointers (/user/name). When state changes, only the elements that read it are updated; the tree is not rebuilt, so inputs keep focus.
The catalog lists the components the model may use. Each needs Zod props and a description; the description is what the model reads. @json-render/core has no ready-made schema for this renderer, so define the flat spec schema:
// server/catalog.ts
import { defineCatalog, defineSchema } from '@json-render/core';
import { z } from 'zod';
const schema = defineSchema((s) => ({
spec: s.object({
root: s.string(),
elements: s.record(
s.object({
type: s.ref('catalog.components'),
props: s.propsOf('catalog.components'),
children: s.array(s.string()),
}),
),
}),
catalog: s.object({
components: s.map({ props: s.zod(), description: s.string() }),
actions: s.map({ description: s.string() }),
}),
}));
export const catalog = defineCatalog(schema, {
components: {
Stack: {
props: z.object({ gap: z.number().optional(), direction: z.enum(['row', 'column']).optional() }),
description: 'Lays out children in a row or column',
},
Stat: {
props: z.object({ label: z.string(), value: z.union([z.string(), z.number()]), delta: z.number().optional() }),
description: 'A KPI. delta is a percentage change, e.g. 12.5 or -3',
},
Button: {
props: z.object({ label: z.string() }),
description: 'A button. Emits "press"; bind it to an action with on.press',
},
},
actions: {
export_data: { description: 'Export the data currently on screen' },
},
});- Every catalog type must also be in the registry.
@ng-json-render/primitivesdoes not include a catalog; the primitives skill has one you can copy. - Use
.optional()for optional props, not.nullable(). An explicitnullis set on the input and replaces its default.
catalog.prompt() returns a system prompt that describes the spec format, your components and your actions. It asks the model to answer in JSONL: one JSON Patch operation per line. The default prompt also describes repeat, which this renderer does not support yet, so add a rule against it. Any LLM SDK works; this example uses the AI SDK:
// server/generate.ts
import { streamText } from 'ai';
import { catalog } from './catalog';
export async function POST(req: Request) {
const { prompt } = await req.json();
const result = streamText({
model: 'anthropic/claude-haiku-4.5',
system: catalog.prompt({
customRules: ['Do not use repeat; write out each element explicitly'],
}),
prompt,
});
return result.toTextStreamResponse();
}The response looks like this:
{"op":"add","path":"/root","value":"main"}
{"op":"add","path":"/elements/main","value":{"type":"Stack","props":{"gap":16},"children":["signups"]}}
{"op":"add","path":"/elements/signups","value":{"type":"Stat","props":{"label":"Signups","value":"1,204"},"children":[]}}To check a complete spec, use catalog.validate(spec), which returns { success, data, error }, or validateSpec(spec).
createSpecStreamCompiler applies patches as they arrive and buffers lines split across chunks. Set each result on a signal and the renderer updates as the UI grows.
import { Component, signal } from '@angular/core';
import { JrRenderer, createSpecStreamCompiler, type JrActionEvent, type Spec } from '@ng-json-render/core';
@Component({
selector: 'app-assistant',
imports: [JrRenderer],
template: `
<form (submit)="$event.preventDefault(); generate(prompt.value)">
<input #prompt placeholder="Describe the UI you want" />
<button [disabled]="streaming()">Generate</button>
</form>
<jr-renderer [spec]="spec()" (action)="onAction($event)" />
`,
})
export class Assistant {
readonly spec = signal<Spec | null>(null);
readonly streaming = signal(false);
async generate(prompt: string) {
this.streaming.set(true);
const compiler = createSpecStreamCompiler<Spec>();
const res = await fetch('/api/generate', { method: 'POST', body: JSON.stringify({ prompt }) });
const reader = res.body!.pipeThrough(new TextDecoderStream()).getReader();
for (;;) {
const { value, done } = await reader.read();
if (done) break;
const { result, newPatches } = compiler.push(value);
if (newPatches.length) this.spec.set({ ...result });
}
this.streaming.set(false);
}
onAction(e: JrActionEvent) {
// e.g. e.action === 'export_data'
}
}If you receive the whole response at once, use compileSpecStream(text) instead.
- Keep the catalog small. Every component and prop is something the model can get wrong. Write descriptions as documentation; they are all the model sees.
- Keep the catalog and registry in sync. A type missing from the registry is skipped; a type missing from the catalog is never generated.
- Treat actions as intents. The model names an action; your handler decides what happens and checks permissions. Don't execute URLs, queries or code from a spec.
- Validate on the server before sending a spec to the client, and handle failures.
For editing an existing spec with a follow-up prompt, see buildUserPrompt in @json-render/core.
If your backend returns a complete spec as JSON, pass it to the renderer directly:
import { httpResource } from '@angular/common/http';
import { Component } from '@angular/core';
import { JrRenderer, type Spec } from '@ng-json-render/core';
@Component({
selector: 'app-settings',
imports: [JrRenderer],
template: `
@if (ui.value(); as spec) {
<jr-renderer [spec]="spec" />
}
`,
})
export class Settings {
ui = httpResource<Spec>(() => '/api/ui/settings');
}Any standalone component can be registered; no base class or decorator is needed.
| Spec | Component |
|---|---|
props |
Set on the input() / model() with the same name. Props without a matching input are ignored. An input named props receives the whole props object. |
children |
Projected into <ng-content />, in order. |
on |
The component raises events with inject(JR_CONTEXT).emit(event, payload?). |
$bindState |
Written back from a model() with the prop's name. |
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
@Component({
selector: 'app-callout',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="callout" [class]="tone()">
<strong>{{ title() }}</strong>
<ng-content />
</div>
`,
})
export class Callout {
title = input('');
tone = input<'info' | 'warn'>('info');
}import { Component, inject, input } from '@angular/core';
import { JR_CONTEXT } from '@ng-json-render/core';
@Component({
selector: 'app-pricing-card',
template: `
<h3>{{ plan() }}</h3>
<button (click)="ctx.emit('select', { plan: plan() })">Choose {{ plan() }}</button>
`,
})
export class PricingCard {
plan = input('');
protected ctx = inject(JR_CONTEXT);
}{ "type": "PricingCard", "props": { "plan": "Pro" }, "on": { "select": { "action": "choose_plan" } } }Expose the editable value as a model(), usually named value (or checked for checkboxes). When the prop uses $bindState, the renderer writes changes back to state. This matches the Signal Forms FormValueControl / FormCheckboxControl shape, so on Angular 21+ the same control also works with the Signal Forms [formField] directive ([field] in early 21 releases).
import { Component, model } from '@angular/core';
@Component({
selector: 'app-text-field',
template: `<input [value]="value()" (input)="value.set($any($event.target).value)" />`,
})
export class TextField {
readonly value = model('');
}{ "type": "TextField", "props": { "value": { "$bindState": "/user/email" } } }import { defineRegistry, mergeRegistries, provideJsonRender } from '@ng-json-render/core';
import { primitivesRegistry } from '@ng-json-render/primitives';
const registry = mergeRegistries(
primitivesRegistry,
defineRegistry({ Callout, PricingCard, TextField }), // later registries win on conflicts
);
provideJsonRender({ registry });Add the new types to your catalog so the model can use them.
- When a component emits an event, the element's
onbinding gives the action name. If there is no binding, the event name is used. - The action is passed to the matching handler in
provideJsonRender({ actions }), if any, and emitted on(action). - Handlers receive
{ action, payload, nodeId, element }. paramson an action binding are not passed. Send data in the eventpayload, or read state in the handler.
@ng-json-render/primitives registers these types in primitivesRegistry:
| Group | Type | Main props |
|---|---|---|
| Layout | Container |
maxWidth |
Stack |
direction (row | column), gap, align, justify, wrap |
|
Grid |
columns, gap, minItemWidth |
|
Card |
title, subtitle |
|
Divider |
none | |
| Content | Heading |
value, level (1–4) |
Text |
value, weight, size |
|
Badge |
label, tone |
|
Stat |
label, value, delta (percentage number) |
|
| Feedback | Alert |
title, message, tone |
Progress |
value (0–100), label |
|
| Data | BarChart |
data: { label, value }[], height |
LineChart |
data: number[], height, label? |
|
Table |
columns, rows |
|
| Forms | Input |
value (model), label, placeholder, type, hint |
Textarea |
value (model), label, placeholder, rows |
|
Select |
value (model), label, placeholder, options |
|
Checkbox, Switch |
checked (model), label |
|
Button |
label, variant, disabled; emits press |
Live examples are on the components page. The primitives skill lists every prop type and default.
| Feature | Status |
|---|---|
$state, $bindState, $template, $cond/$then/$else |
Supported |
on event → action bindings |
Supported |
visible conditions |
Supported; evaluated when the tree is built |
Streaming (createSpecStreamCompiler) |
Supported |
repeat, $item, $index, $bindItem |
Not yet |
$computed, watch, validation checks |
Not yet |
Named slots |
Not yet (only children) |
Action params, built-in actions (setState, …) |
Not yet |
Skills for AI coding agents (Claude Code, Cursor, Codex):
npx skills add garatropic/ng-json-render --skill ng-json-render # @ng-json-render/core
npx skills add garatropic/ng-json-render --skill ng-json-render-primitives # components, props, catalog
npx skills add vercel-labs/json-render --skill core # catalogs, prompts, spec formatrenderSpec() mounts a real <jr-renderer> in TestBed:
import { renderSpec } from '@ng-json-render/core/testing';
import { primitivesRegistry } from '@ng-json-render/primitives';
const r = renderSpec(
{ root: 'b', elements: { b: { type: 'Button', props: { label: 'Save' }, on: { press: { action: 'save' } } } } },
{ registry: primitivesRegistry },
);
expect(r.query('button')?.textContent).toContain('Save');
r.query('button')?.click();
expect(r.actions.map((a) => a.action)).toEqual(['save']);Options are { registry, actions?, state? }. The result has fixture, host, actions, setSpec(spec), query(selector) and queryAll(selector).
git clone https://github.com/garatropic/ng-json-render.git
cd ng-json-render
pnpm install
pnpm exec nx serve demo # docs site
pnpm exec nx run-many -t lint test build # all projectsThe docs site (apps/demo) deploys to GitHub Pages on every push to main.
Both packages share one version. From an up-to-date main:
pnpm release:dry-run 1.0.0-beta.0 # preview (or: patch, minor, major, prerelease)
pnpm release 1.0.0-beta.0This bumps the versions, updates CHANGELOG.md, commits, tags v<version>, pushes and creates a GitHub Release. Publishing the release triggers the Release workflow, which runs CI and publishes both packages to npm with trusted publishing and provenance. Prerelease versions go to the next dist-tag, others to latest.
MIT © Maina Wycliffe