JSON-Render

JSON-render describes a UI as data: a serializable component spec any frontend renders. Opt-in: a plain devframe pulls zero JSON-render dependencies. Two packages:

JSON-render describes a UI as data: a serializable component spec any frontend renders. Opt-in: a plain devframe pulls zero JSON-render dependencies. Two packages:

  • @devframes/json-render: framework-neutral protocol layer (spec/catalog types, prop schemas, view refs, node runtime), built on @json-render/core.
  • @devframes/json-render-ui: reference Vue frontend implementing the base catalog with @antfu/design.

Authoring a view

createJsonRenderView registers the spec as shared state, returning a handle:

import { createJsonRenderView } from '@devframes/json-render/node'

export default defineDevframe({
  /** … */
  setup(ctx) {
    const view = createJsonRenderView(ctx, {
      id: 'metrics', // stable, unique within the scope
      spec: {
        root: 'root',
        elements: {
          root: { type: 'Card', props: { title: 'Live metrics' }, children: ['count'] },
          count: { type: 'Text', props: { text: { $state: '/count' } }, children: [] },
        },
        state: { count: 0 },
      },
    })

    // A structural change replaces the whole spec…
    view.update(nextSpec)
    // …while state travels as JSON-Pointer patches (only the changed path crosses the wire).
    view.patchState([{ op: 'replace', path: '/count', value: 3 }])

    // Unregisters the shared state and its listeners.
    // view.dispose()
  },
})

The view id is scoped devframe:json-render:<scope>:<id>, scope defaulting to the namespace or global. Diagnostics fire on invalid props (DF0038), duplicate id (DF0039), disposed-view use (DF0040), non-serializable spec (DF0041).

The base catalog

Catalog v1 ships fourteen components: Stack, Card, Text, Badge, Button, Icon, Divider, TextInput, Switch, KeyValueTable, DataTable, CodeBlock, Progress, Tree. A spec is an @json-render/core Spec plus a per-component Zod prop schema (basePropSchemas), validated at ingress (node side) and render time (browser side); $state / $bindState bindings work on scalar props.

Actions and state

  • State is a JSON-serializable Record<string, unknown> addressed by JSON Pointer.
  • Actions are unrestricted: an element event dispatches an RPC call of the same name, with no allowlist.
  • Reserved built-ins (setState, pushState, removeState, validateForm) are handled client-side, never bridged to RPC.

Rendering standalone

Out-of-box SPA (no client build)

createJsonRenderDevframe points clientAssets at the prebuilt @devframes/json-render-ui/spa renderer:

import { createJsonRenderDevframe } from '@devframes/json-render-ui/spa'
import { createJsonRenderView } from '@devframes/json-render/node'

export default createJsonRenderDevframe({
  id: 'my-app',
  name: 'My App',
  version,
  packageName,
  homepage,
  description,
  cli: { command: 'my-app', port: 9800 },
  setup(ctx) {
    createJsonRenderView(ctx, { id: 'main', title: 'Dashboard', spec })
  },
})

The SPA discovers views from the view index (JSON_RENDER_INDEX_KEY); one view renders full-bleed, multiple get a title-labeled switcher.

Custom frontend

A custom frontend renders from shared state: connect with connectDevframe(), read the view's state (keyed devframe:json-render:<scope>:<id>), subscribe to updated events, and render with your registry; the Next hub example has a React renderer. In a static build spec + state are read-only: actions unavailable, local state and bindings still work.

The reference frontend

@devframes/json-render-ui ships prebuilt /spa and /hub bundles.

Rendering inside a hub

@devframes/json-render/hub adds a json-render dock type:

// node side: register a dock entry carrying the view's serializable reference,
// and compose the frontend as a prebuilt renderer module
import { jsonRenderUiRenderer } from '@devframes/json-render-ui/hub'
import { toJsonRenderDockEntry } from '@devframes/json-render/hub'

initHub({
  ui: createUi(),
  renderers: [jsonRenderUiRenderer()],
  configure(ctx) {
    ctx.docks.register(toJsonRenderDockEntry(view, {
      id: 'metrics',
      title: 'Metrics',
      icon: 'ph:chart-bar-duotone',
    }))
  },
})

The hub publishes the module in the renderer manifest, lazily imported on first mount (else a missing-renderer fallback). A host page can register a JsonRenderDockRenderer locally instead, overriding the manifest:

// host page: a locally-bundled frontend wins over the manifest module
import { createDevframeClientRuntime } from '@devframes/hub/client'
import { myJsonRenderDockRenderer } from './my-renderer'

const host = await createDevframeClientRuntime({
  renderers: { 'json-render': myJsonRenderDockRenderer },
})

// the hub UI provider mounts the active dock entry into a container it owns
const result = await host.context.renderers.mount(entry, container)
if (result.status === 'mounted')
  result.dispose // tear down when the hub UI provider decides; deactivation disposes too

The dock entry carries a serializable JsonRenderViewRef in two shapes: { stateKey } points at live shared state (createJsonRenderView); { spec } embeds it inline for a browser-synthesized client-only dock.

Swapping the frontend

@devframes/json-render/hub exports the contract, JsonRenderDockRenderer and JsonRenderDockMountOptions; @devframes/json-render-ui is the pluggable reference implementation. See Build your own JSON-Render frontend and the json-render example.