Georgii EmelianovEngineering

Render SwiftUI from JSON, safely

The libraries that own this problem have all shipped a default for the unknown-component case. Every one of those defaults was chosen for a human author, and you are about to hand the JSON to a model.

Torn white, cool grey-violet, and black paper framing the headline "Render SwiftUI from JSON, safely".

Decoding a JSON blob into SwiftUI views takes an afternoon. You write an enum of component types, a switch that returns a view for each, and a recursive call for children. It works on the first payload you try, because you wrote the payload.

The question that stops the project is the next one. To render SwiftUI from JSON safely you have to decide what happens when the JSON is wrong — malformed, adversarial, or naming a component you never registered — and you have to decide it before the payload stops being written by you. Once a model composes the screen at request time, every property of that payload becomes a runtime question, including whether it is a payload at all.

The short version

  1. The registry is an allowlist. A closed set of component types the app has compiled in. Not a string-keyed dictionary you can extend at runtime.
  2. An unknown component is dropped. Not placeheld, not shown as an error card. It never becomes pixels.
  3. The JSON carries no style. No colours, no fonts, no spacing. If the payload can set them, the sender picks your design system.
  4. Actions are references the host declared. The payload names which of your actions to show. It never carries a URL, a label, or a destination.
  5. Validation happens before the bytes reach the device, and again on it. A client-only validator puts your rules in a binary you have to ship to change.
  6. An invalid record is rejected, never repaired. Patching a broken node produces a screen nobody designed.

Everything below is one of those six, why the obvious alternative loses, and what it costs.

Why the obvious approach breaks

The first version everyone writes looks like this, and there is nothing wrong with it:

enum Node: Decodable {
    case text(String)
    case stack([Node])
    case button(label: String, action: String)
}

@ViewBuilder
func render(_ node: Node) -> some View {
    switch node {
    case .text(let s):            Text(s)
    case .stack(let children):    VStack { ForEach(children, id: \.self, content: render) }
    case .button(let label, let action):
        Button(label) { handle(action) }
    }
}

It is safe in the only sense that matters at that moment: an unexpected shape fails to decode, and Codable throws. What breaks is not the code. It is the assumption underneath the code — that the JSON was written by someone with your interests, ahead of time, and reviewed.

Change the author to a model and four things become true at once. The type string is now attacker-influenced input rather than a constant. The label is composed at request time rather than by a designer. The action string is a destination the payload chose. And the whole document arrives in fragments over a network, so "decode it, then check it" has to become "check each piece as it lands".

That is the real subject. The rendering was never the hard part.


Decision 1 — the registry is an allowlist, not a lookup table

Every library in this space has a component registry, and the difference between a safe one and an unsafe one is whether the set is closed.

An open registry maps a String to a builder and lets callers add entries. It is the right design for a plugin system, where the extension is written by a developer. It is the wrong design here, because the set of things that can appear on screen is then decided by whatever populated the dictionary, and the type check has become a dictionary lookup that either hits or misses.

A closed registry makes the component type a compile-time enum. The payload does not select a builder; it matches a case or fails.

enum RecordType: String, Decodable {
    case narrative, facts, metrics, callout
    case collectionStart = "collection_start"
    case collectionItem  = "collection_item"
    case handoffBar      = "handoff_bar"
    case screenCommit    = "screen_commit"
    // …the complete set, and nothing else
}

// An unregistered type is not a miss to be handled. It is a decode failure.
guard let type = RecordType(rawValue: raw.type) else {
    throw PresentationStreamError.recordNotAllowed(raw.type)
}

The practical difference shows up in review. With a closed enum, "which components can this screen contain?" is answered by reading one file. With a string-keyed registry, it is answered by tracing every call site that registers something, in every build configuration.

Decision 2 — an unknown component is dropped, not placeheld

This is where the shipped defaults are actively wrong for a model-authored payload, and it is worth naming them.

swiftui-json-render renders a grey box by default. Its README (checked 16 August 2026) documents unknownComponentBehavior with three modes — .placeholder, described as "Gray placeholder (default)", plus .skip and .error. The library is explicitly aimed at AI-generated interfaces.

DynamicUI gets this one right. Its README (checked 16 August 2026) states that unknown component types render no view.

A placeholder is a sensible default for a design tool, where the unknown node means "a colleague used a component you have not pulled yet" and the grey box is a message to a developer. When the author is a model, the same grey box is a message to your customer, rendered inside your app's chrome, saying that something was supposed to be here and your app does not know what. An error card is worse: it is a visible admission of a failure the user cannot act on, at full width, in a screen they asked a question to get.

Dropping is not error suppression. It is the recognition that a node you cannot identify has no correct rendering, and that the least wrong thing an app can do with it is nothing.

The stronger version of this rule is that an unknown node should not reach the renderer at all — it should have failed validation while the screen was still a stream of bytes. Which is Decision 5.

Decision 3 — the JSON carries no style

DynamicUI's README documents modifiers on a component, with {"foregroundStyle": "red", "opacity": 0.6} as the worked example (checked 16 August 2026). For a JSON file a developer commits, that is a feature. For a payload a model writes, it means the model picks the colour.

The failure is not that the model will choose something ugly, though it will. It is that your design system stops being enforceable. Brand review can no longer be done by reading the app, because the app's appearance is a function of a payload that does not exist until a customer asks a question. Every audit becomes a sampling exercise.

The fix is structural rather than procedural: give the contract nowhere to put a style. If no colour, font, spacing or corner-radius field exists in the schema, no instruction reaching the model can produce one, and the theme stays in Swift where it can be reviewed. That is the same argument in more detail as keeping generated UI inside your design system, and it is the one constraint here that costs almost nothing to adopt.

Decision 4 — actions are references the host declared

The payload should never say what a control does. It should say which of your controls to show.

Concretely: the wire carries an identifier — an actionRef — that the app registered in advance, and the label and behaviour are resolved on the device from the app's own configuration. No URL field, no label field, no payload field. An identifier the app was not configured with is dropped, and it does not receive a button trait, so it is neither tappable nor announced as tappable to VoiceOver.

The alternative — a url or a deeplink string in the JSON — hands whoever writes the payload a navigation primitive inside an authenticated session. That is the difference between a content bug and an incident, and it is one of the constraints in what a generated screen is structurally unable to do.

Decision 5 — validate before the bytes reach the device, and again on it

Both libraries put validation in the app. swiftui-json-render exposes JSONValidator.validate(json), returning .valid or .invalid([errors]), called by you, before rendering. That is better than nothing and it has two structural problems.

The rules ship in the binary. Tightening a limit means an App Store release and a tail of old versions running the old rules for months. The validator you most want to change quickly is the one you can change least quickly.

You cannot validate what has not arrived. The same library offers a StreamingJSONRenderer that appends chunks as they come. A half-received document cannot be checked against a schema, so the streaming path and the validation path are pulling in opposite directions, and the README does not say what happens when a payload fails validation after part of it is already on screen.

The shape that resolves both: validate each complete unit server-side, before it is forwarded, and re-validate on the device. Two independent implementations of the same rules, with the tighter of the two winning. The server can be tightened the same afternoon; the client is the backstop for a server that is wrong, and neither is trusted alone.

Decision 6 — an invalid record is rejected, never repaired

The tempting move when a node fails is to fix it — fill the missing field, clamp the out-of-range value, coerce the type. It keeps the screen rendering, and it is the decision that quietly destroys the value of every other decision on this list.

A repaired record is a record nobody designed. The contract now describes what the model emits plus whatever your repair function invents, and that second half exists in no schema and gets no review. Rejecting rather than repairing keeps the set of things that can render equal to the set of things you specified.

What this still does not protect you from

A schema-valid screen can still be wrong. Every rule here constrains shape. None of them makes the content true, and a well-formed metrics row showing a number nothing returned is the most convincing thing on the screen.

Structure is not provenance. Binding a rendered value to a tool result is a separate check, and no amount of schema tightening substitutes for it.

This is a rendering boundary, not an application security model. Authentication, session handling, rate limiting and what your API returns to whom are unchanged and still yours.

And the closed registry has a real cost. Adding a component means editing an enum and shipping a build. There is no runtime extension point, which is exactly the property that makes the guarantee hold — and it is the wrong tradeoff if what you actually want is to iterate on layout without releases. That use case is server-driven UI with a human author, and it is a different product.

How Uzori does it

The six decisions above describe the shipped design, so it is worth reading as one worked example rather than a claim.

The wire is a catalogue of 23 flat record types, streamed one JSON object per line. Every definition is additionalProperties: false, and there is no colour, font, spacing or nesting field anywhere in it. Style is resolved in UzoriStyle.swift on the device and is never serialized to the server. Here is a complete record:

{"type":"metrics","screenId":"scr_01","heading":"This month","items":[{"label":"Data","value":"14.2 GB","tone":"negative"},{"label":"Calls","value":"212 min"}]}

Before that line is forwarded, it passes six checks in order: JSON Schema, screen identity, navigation match, stream grammar state, grounding, and cardinality. The gate then forwards the original bytes, so what renders is byte-identical to what passed — there is no re-serialisation step in which something could be reintroduced. An invalid line is rejected rather than patched; one bounded re-ask is permitted only when the first record fails and nothing has reached the client.

On the device, the reducer re-implements the same grammar and its own limits rather than trusting the gate. Where the two disagree, the tighter wins — the server caps a collection at 20 items, the client's own guard sits at 40, so 20 is what holds, and a server bug that raised the server's limit would still not get past the client. The renderer is native SwiftUI throughout: a repo-wide search for WKWebView, React, JavaScriptCore, Flutter and Hermes returns nothing.

Frequently asked questions

How do I render SwiftUI views from a JSON file?

Decode the JSON into a typed model with Codable, map each type to a view in a switch, and recurse for children. That is a short afternoon and it is the right answer when you write the JSON. When something else writes it, the decoding is unchanged and the surrounding decisions — closed registry, drop on unknown, no style in the payload — are what the work actually consists of.

What happens if the JSON names a component that does not exist?

That is a policy you choose, and the shipped defaults differ. swiftui-json-render renders a grey placeholder unless you configure .skip or .error; DynamicUI renders no view. For a model-authored payload, drop it — a node you cannot identify has no correct rendering, and a placeholder puts an unexplained artefact inside your own chrome.

Can Codable validate the JSON before I render it?

Only partly, and in the wrong direction. JSONDecoder enforces the types and the required keys you declared, then silently ignores every key it does not recognise. If unknown keys must be an error, you write that comparison yourself. Range limits, cardinality and ordering are not Codable's job at all and need a separate pass.

Is this the same as server-driven UI?

The transport is identical: a serialized description of an interface, rendered natively. The difference is the author. In server-driven UI a human commits the tree ahead of time, so validation is a build-time concern. When a model composes it per request, every property becomes a runtime question — which is why the registry, the drop rule and the gate all exist.

Where to start

Take the renderer you already have and answer the six questions against it, in order. Most teams find that decisions 1, 5 and 6 are already half-made and that decisions 2, 3 and 4 are wide open — because the library defaults were chosen for a human author.

If you would rather read a contract than write one, the shipped wire schema and stream grammar are what we would show you first: 23 records, a state machine, and the six checks each line passes. Email hello@uzori.ai and ask for them.

← All posts