Skip to content

About

Angular renderer for json-render: turn AI-generated or server-driven JSON specs into UI built from your own Angular components.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

ng-json-render

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)

Overview

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.

How it works

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>
  1. You define a catalog. catalog.prompt() turns it into a system prompt.
  2. The model responds with a spec, streamed as JSON patches.
  3. <jr-renderer> renders each element with the component registered for its type. Types not in the registry are skipped.
  4. Components raise actions. The spec only names them; your handlers decide what runs.

Packages

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.

Installation

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.

Tailwind (primitives only)

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

Quick start

1. Provide a registry

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),
      },
    }),
  ],
};

2. Render a spec

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.

Spec format

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, $or or not. 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.

Generating specs with AI

1. Define a catalog (server)

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/primitives does not include a catalog; the primitives skill has one you can copy.
  • Use .optional() for optional props, not .nullable(). An explicit null is set on the input and replaces its default.

2. Generate the prompt and call the model (server)

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

3. Render the stream (Angular)

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.

Guidelines

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

Server-driven UI

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');
}

Custom components

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.

Display component

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');
}

Interactive component

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" } } }

Form controls

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" } } }

Register

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.

Actions

  • When a component emits an event, the element's on binding 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 }.
  • params on an action binding are not passed. Send data in the event payload, or read state in the handler.

Built-in components

@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 support

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

Agent skills

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 format

Testing

renderSpec() 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).

Development

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 projects

The docs site (apps/demo) deploys to GitHub Pages on every push to main.

Releasing

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

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

License

MIT © Maina Wycliffe

About

Angular renderer for json-render: turn AI-generated or server-driven JSON specs into UI built from your own Angular components.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages