All posts

Document-Driven UI

6 min read

Document-Driven UI

Short notes on Document-Driven UI: why I think the document, and not the server, is the interesting half of Server-Driven UI, and what changes once the transport stops being part of the architecture.

Motivation

I have been thinking about and researching this since the beginning of the year. I tested several commercial and open-source SDUI projects, and read about how other teams lived with it, in particular the journey to Server-Driven UI at Lyft Bikes and Scooters by Tim Miko and Alex Hartwell. After that, I decided to slowly define the specs and conformance tests that today shape Milano, so that I could focus on document rendering and validation first and leave everything else for later.

The idea itself still fits in one sentence: a screen, or part of it, can be a document, and where that document comes from should not be the framework’s business. As usual, treat this as notes from someone who is still learning, not as the definitive guide.

Abstract

Server-Driven UI (SDUI) has been around for years, and teams like Lyft’s have published their own take on it. The pitch is simple. Instead of shipping layout code inside the app, the backend returns a description of the UI and the client renders it. Banners, promos, onboarding, forms: anything that changes faster than your release train can change without waiting on an app store review.

The problem is the name. “Server-Driven” describes two separate ideas as if they were one:

  1. The UI is a document. A declarative description of structure and behaviour that a client can validate and render.
  2. A server produces and sends that document.

The first idea is the valuable one. The second is a delivery choice that got promoted to architecture.

What the server drags along

Once the server is in the name, it ends up in the design. Most SDUI implementations I have seen, or built, come with the same baggage:

  • A backend that assembles the UI, usually a Backend For Frontend, with one endpoint per surface.
  • A client that cannot render anything before a network round trip.
  • Tests that need a running server, or a mock that drifts away from the real one.
  • Offline as an afterthought, bolted on with caches and stale flags.
  • A client/server handshake to version, where “what the app understands” and “what the backend emits” must be negotiated on every request.
  • A coupling between the app’s release train and the backend’s deploy pipeline, which is exactly the coupling SDUI promised to loosen.

None of that is required by “the UI is a document”. All of it is required by “a server sends it”.

The document is the contract

Call the description a document. A document is a JSON object that declares the version of the contract it was written for, the vocabulary it needs, the shape of the data it reads, and a tree of nodes. Each node has a type, some properties, and maybe children and event bindings.

{
  "version": "2.1.0",
  "vocabulary": { "name": "promo", "min": "1.0.0" },
  "context": { "userName": "string" },
  "root": {
    "type": "Column",
    "children": [
      {
        "type": "Text",
        "properties": {
          "text": { "$expr": "$concat('Summer sale, ', context.userName)" },
          "role": "title"
        }
      },
      {
        "type": "Button",
        "properties": { "label": "See the offer", "enabled": true },
        "on": {
          "tap": [{ "action": "openUrl", "url": "https://shop.example.com/sale" }]
        }
      }
    ]
  }
}

The types come from a vocabulary, and the vocabulary is yours. It is the list of components, properties, events and actions you already own in SwiftUI, Compose and React, written down as a schema. Text and Button are not framework widgets; they are your design system’s components with a name the document can use.

The document describes structure, never data. It declares that it reads a userName from context, but the value comes from the app at runtime, and the same document renders a different user without changing a byte. That split is what makes a document cacheable on its own, independently of the data it will show. The only way out of a document is a declared action, like openUrl above: the document requests it, the app decides what it means.

The runtime in the app does two things and nothing else. It validates the document against the vocabulary, and it resolves the document into calls to your components. It draws nothing. Your design system stays where it is, and the document only decides which of your components appear, with which properties, in which order.

Validation is the gate. An invalid document is rejected before anything renders, so a document either works on every platform or fails on every platform. No half-rendered screens, no “it looks fine on iOS but crashes on Android”. Versioning becomes contract versioning, like a file format: each version is a superset of the previous one, and every older document keeps meaning the same thing.

Transport is not the framework’s business

Once the contract is the document, where the bytes come from stops mattering. The same document can arrive from:

  • The app bundle. Ships with the app, works on first launch, works offline.
  • A CDN. A static file, cached, cheap, and deployed with the same tooling as any other asset.
  • A feature-flag payload. A/B test a whole surface instead of a boolean.
  • Your API. This is SDUI. It is now one delivery option, not the architecture.
  • A test fixture. A JSON file next to the test, no server involved.
  • A script, a CMS, a CI job, or an agent. The producer of a document does not have to be a backend.

Same document, same UI. The consequences are what I find interesting:

  • The backend is optional. Start with bundled documents, add remote ones when you need them, or never.
  • Tests are cheap. A document, a vocabulary and the data the app injects are all a test needs; no server is involved, and the same document renders different data without changing a byte.
  • Offline is not a feature. It is a fallback document.
  • Adoption is incremental. One surface at a time, without a platform team building a screen-assembly service first.
  • The producer is anything that can write valid JSON. Which today includes a large language model, as long as the gate is there to reject what it gets wrong.

SDUI got the important idea right and then tied it to the wrong noun. Document-Driven UI is the same idea, named after the part that matters.

Highlights

  • SDUI is two ideas: the UI is a document, and a server sends it. Keep the first.
  • The document is the contract. Versioned, validated, and readable by every platform.
  • Structure, never data. A document declares the shape of what it reads; the app injects the values.
  • The vocabulary is yours. The runtime validates and resolves; it never draws.
  • Validate at the gate. A document either works everywhere or fails everywhere.
  • Transport is a delivery decision: bundle, CDN, flag, API, fixture. Server-driven becomes one option among several.

One more thing

I built the thing I am describing. Milano is a client-only, design-system-agnostic Document-Driven UI framework for SwiftUI, Compose and React, with a normative specification, an SDK and a playground. It never talks to a server, it is OSS hosted at GitHub. That was the point.

More posts