Skip to content

JSON-Render ​

JSON-render describes a UI as data — a serializable component spec any frontend renders. Opt-in: a plain app 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:

ts
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/coreSpec plus a per-component Zod prop schema (basePropSchemas), validated at ingress (server) and render time (client); $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 — 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:

ts
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:

ts
// server — register a dock 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:

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

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

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

The dock 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.

Released under the MIT License.