DEV Community

Maxim Berenshtein
Maxim Berenshtein

Posted on

Generative UI in Angular: the LLM streams a JSON spec, you render real components

There are two ways to let a model build a UI, and only one of them is a good idea.

The first: ask it for HTML and drop the result into innerHTML. It works in a demo and falls apart everywhere else. You get no design system, no event handlers, no type safety, and a permanent XSS surface with the model on the wrong side of it.

The second: give the model a vocabulary — a catalog of components you already ship — and let it describe the UI it wants in a constrained JSON format. Your renderer maps that description onto your actual components. The model never emits markup, never emits code, and literally cannot reference anything outside the catalog you handed it.

json-render from Vercel Labs is a well-built implementation of the second idea. It had official renderers for React, Vue, Solid and Svelte. It did not have one for Angular.

So I wrote ngx-json-render.

A SpecStream of RFC 6902 patches rendering progressively into an Angular dashboard

The shape of the thing

Three pieces, and it's worth being precise about which one does what.

The catalog is your vocabulary — component names, Zod schemas for their props, a description of each so the model knows when to reach for it, and optionally a set of actions:

import { schema } from 'ngx-json-render';
import { z } from 'zod';

export const catalog = schema.createCatalog({
  components: {
    Card: {
      props: z.object({ title: z.string().optional() }),
      slots: ['default'],
      description: 'A card container',
    },
    Button: {
      props: z.object({ label: z.string() }),
      slots: [],
      description: "A button that emits a 'press' event",
    },
  },
  actions: {
    refresh: { params: z.object({}), description: 'Reload the data' },
  },
});
Enter fullscreen mode Exit fullscreen mode

catalog.prompt() turns this into the system prompt that teaches the model the vocabulary and the output format. catalog.jsonSchema() gives you a JSON Schema if you'd rather use structured output or a tool call. catalog.validate(spec) checks a finished spec against the catalog.

The spec is what comes back — a flat map of elements, each with a type from the catalog, props, and child keys:

{
  "root": "root",
  "state": { "count": 0 },
  "elements": {
    "root": { "type": "Card", "props": { "title": "Hello" }, "children": ["btn"] },
    "btn": {
      "type": "Button",
      "props": { "label": "Tap me" },
      "on": { "press": { "action": "setState", "params": { "statePath": "/count", "value": 1 } } },
      "children": []
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Flat, not nested — which matters more than it looks, and I'll come back to it when we get to streaming.

The renderer walks that spec and instantiates your components:

<json-render [spec]="spec()" [registry]="registry" [handlers]="handlers" />
Enter fullscreen mode Exit fullscreen mode

The catalog components are ordinary Angular components. They read their props from an injected render context and render their children wherever they like:

@Component({
  selector: 'app-card',
  imports: [JrChildren],
  template: `
    <section class="card">
      @if (ctx.props().title) { <h3>{{ ctx.props().title }}</h3> }
      <jr-children />
    </section>
  `,
})
export class CardComponent {
  readonly ctx = injectRenderContext<{ title?: string }>();
}
Enter fullscreen mode Exit fullscreen mode

<jr-children /> is a router-outlet for the spec tree — it renders this element's children at that position. Named slots work the same way: <jr-children slot="header" />.

defineRegistry ties catalog names to component classes, and it's typed against the catalog — register a component the catalog doesn't declare and the compiler stops you:

const { registry, handlers } = defineRegistry(catalog, {
  components: { Card: CardComponent, Button: ButtonComponent },
  actions: { refresh: async (params, setState) => { /* ... */ } },
});
Enter fullscreen mode Exit fullscreen mode

What the spec can actually express

A format like this lives or dies on whether it can describe a real screen rather than a static mock. The baseline json-render contract is fairly rich, and ngx-json-render implements all of it:

Dynamic props { "$state": "/user/name" }
Two-way binding { "$bindState": "/form/email" }, { "$bindItem": "done" }
Conditionals { "$cond": {...}, "$then": ..., "$else": ... }
Templates { "$template": "Hello, ${/user/name}" }
Visibility "visible": { "$state": "/count", "gte": 5 }
Repeat "repeat": { "statePath": "/todos", "key": "id" }, nestable
Events → actions "on": { "press": { "action": "...", "confirm": {...}, "onSuccess": ... } }
Watch "watch": { "/country": { "action": "loadCities" } }
Validation field rules, validateForm, injectFieldValidation

Every <json-render> owns a state store addressed by JSON Pointer. setState, pushState, removeState and validateForm are built in, so the model can wire up a working form without you writing a handler for every button. Anything beyond that is a named action you implement in TypeScript — the model can only request it by name, with params validated against your Zod schema.

Actions can declare a confirmation: "confirm": { "title": "Delete this?" } renders a real dialog and waits for the user before the handler runs. Which is the general theme — the model proposes, your code disposes.

Streaming is the interesting part

Here's the thing that makes this feel different from ordinary server-driven UI.

A model generating a dashboard takes several seconds. You could wait for the whole spec and then render it — spinner, pause, pop. Instead, json-render streams the spec as JSONL: one RFC 6902 patch per line:

{"op":"add","path":"/root","value":"page"}
{"op":"add","path":"/elements/page","value":{"type":"Stack","props":{},"children":["title"]}}
{"op":"add","path":"/elements/title","value":{"type":"Heading","props":{"text":"Weekly sales"},"children":[]}}
Enter fullscreen mode Exit fullscreen mode

Each line is applied to the spec signal as it arrives, so the UI assembles on screen while the model is still generating. This is where the flat element map earns its keep: a patch can add a deeply nested element without rewriting its ancestors, and add /elements/foo is a single, order-independent line.

On the client that's one hook:

@Component({
  template: `
    <json-render [spec]="ui.spec()" [registry]="registry" [loading]="ui.isStreaming()" />
    <button (click)="ui.send('A dashboard for weekly sales')">Generate</button>
  `,
  imports: [JsonRenderer],
})
export class GeneratePage {
  readonly ui = injectUIStream({ api: '/api/generate' });
  readonly registry = registry;
}
Enter fullscreen mode Exit fullscreen mode

injectUIStream POSTs { prompt, context, currentSpec } and expects a JSONL body back. The loading input tells the renderer to tolerate dangling child references — mid-stream, an element routinely names children that haven't arrived yet, and that's normal rather than a bug to warn about.

The server is whatever streams text. With the AI SDK it's genuinely this small:

app.post('/api/generate', async (req, res) => {
  const result = streamText({
    model: anthropic('claude-sonnet-5'),
    system: catalog.prompt(),
    prompt: req.body.prompt,
  });

  res.setHeader('Content-Type', 'text/plain; charset=utf-8');
  for await (const chunk of result.textStream) res.write(chunk);
  res.end();
});
Enter fullscreen mode Exit fullscreen mode

There's also injectChatUI for the chat case, where an assistant message can carry prose and a spec — the stream is split into text lines and fenced JSONL, and each message ends up with a text and an optional spec.

Why it's built the way it is

The renderer is signals all the way down: input() / output(), computed, OnPush on every component, no zone.js requirement. That isn't box-ticking. A streaming spec means dozens of small state changes per second, and a computed graph over a flat element map only recomputes the elements a patch actually touched.

Everything sits on @json-render/core — the same spec grammar, expression evaluator, state store, action dispatcher and stream compiler the React, Vue, Solid and Svelte renderers use. A catalog is framework-specific (it names your components), but a spec is portable: the same generated JSON renders in any of them. That's also why I didn't invent a dialect. Parity with the baseline contract was the whole point, and the library is laid out so src/lib could be adapted into a packages/angular PR upstream.

If you'd rather not write a catalog before seeing anything work, there's a companion package — ngx-json-render-material, a ready-made catalog of 28 Angular Material components — so you can generate and render a spec on day one.

How this differs from ngx-gen-ui

There is one other Angular library that turns up under "generative UI", and the two get filed together, so it is worth saying plainly which problem each one solves.

ngx-gen-ui puts the model call in the component. You attach a directive to an element, give it a prompt, and the response streams into the template. Its structured mode constrains the model to a schema of markup primitives — the README's own example is { "tag": "h1", "content": "Title" } — and converts that JSON into DOM elements. Firebase with Vertex AI is the default provider, with an AiAdapter interface for swapping in another one, though firebase and @firebase/ai are non-optional peer dependencies either way. MIT, Angular ≥ 17.

ngx-json-render never talks to a model at all. Generation happens on your server; the library only consumes the resulting stream. And the vocabulary is not HTML — it is your component catalog, so what comes out the other end is your Card, your DataTable, your ConfirmButton, with their own inputs, their own styles, and their own event handlers.

That difference decides what the generated UI can do. Markup primitives give you a rendered document. A component spec gives you an interface: state bindings, two-way $bindState, events mapped to actions you implemented in TypeScript, confirm dialogs, form validation, repeat over a state array. The model can hand you a working form rather than a picture of one.

So, roughly:

  • Want a model's prose streamed into a page, with Firebase already in your stack? ngx-gen-ui is less work — a directive, and no server of your own.
  • Want the model to assemble screens out of your existing design system, with real handlers behind the buttons, in a format that is portable to React/Vue/Solid/Svelte? That is what the json-render spec is for, and this is the Angular renderer for it.

They are not really competitors. If anything, the interesting thing is that Angular now has two takes on this at all.

Try it

  • Live demo — an interactive spec with bindings, repeat, confirm and watch, plus a replayable SpecStream. The Streaming tab is the one to look at.
  • Repo · npm · Open in StackBlitz
npm install ngx-json-render @json-render/core zod
Enter fullscreen mode Exit fullscreen mode

Angular ≥ 20, Apache-2.0 — matching upstream.

It's young. It has full parity with the baseline renderer contract and a real test suite, but @json-render/core moves fast and I track it release by release. Issues, PRs, and "why didn't you just…" questions are all welcome.

Top comments (0)