Server-driven UI: a field guide for app teams
Server-driven UIGuide18 min readMiniwall team
Updated : Added a link to the post on tracking remote screens.
On this page
Use server-driven UI for the screens the business keeps wanting to change, and for the regions inside a native screen where that is true. Leave the rest native. If you can't name who edits the screen and how often, don't build a renderer yet.
That's the whole opinion. This guide is the long version: how to decide, and then what you take on once you've said yes. It covers the renderer, caching, the schema, security, accessibility, measurement, testing and day-to-day operation. Each part ends with where to read more, and some parts are short on purpose because we'd rather say less than guess.
Steps in order, with who does each: Before you build or Once it's live.
- Before you build: Decide the scope
- Before you build: Define the schema
- Before you build: Build the renderer
- Before you build: Test with fixtures
- Once it's live: Fetch, cache, fall back
- Once it's live: Validate, then act
- Once it's live: Measure
- Once it's live: Publish, roll back
If the term is new to you, what server-driven UI is takes five minutes. We'll assume you know the shape: the server describes a screen, a renderer in the app draws it, and the app still decides what a tap does.
Pick a scope before you pick a format
There are four sensible answers to "how much of this screen should come from the server", and one of them is nothing.
| Scope | Choose it when | Skip it when |
|---|---|---|
| Whole screen | The screen is mostly text, images, buttons and lists, and it changes often (onboarding, promotions, upgrade prompts) | The screen is the product: heavy interaction, custom gestures, animation tied to touch |
| One region of a native screen | The shell is stable and one block keeps changing, such as a banner, a card or an offer strip | The region needs to talk to its neighbors all the time |
| Data only | The layout is fixed and only strings, prices or image URLs vary | You need to add or reorder blocks |
| Nothing | It changes a couple of times a year, or your team ships daily on one platform | Someone is waiting on you every week |
Swipe sideways to see every column.
Our default is the second row. A region gives you most of the speed and a fraction of the exposure. The native screen around it keeps its tests, its accessibility work and its release cycle. If the region turns out to be wrong for you, you've lost a small block, not a screen.
The fourth row is not a joke. A schema, a renderer, a preview and a publish flow are real work, and they only pay for themselves if someone uses the freedom. We have a practical test for that: list the last five changes anyone asked for on this screen. If most were text, order or images, a server-driven region fits. If most were behavior, it doesn't. Where server-driven UI helps and where it hurts goes through that reasoning in more detail, including the honest limits of the approach.
Four questions settle most cases, in this order:
- Who edits the screen, and are they the person who ships the build? If it's the same person, the main benefit mostly disappears.
- How often does it change? Weekly changes justify machinery. Twice a year doesn't.
- How many platforms? One description drawn on web, iOS and Android keeps wording from drifting. On one platform you have less to gain.
- Who owns the schema? Someone has to say no to the fortieth component. Without that person the format grows until nobody understands it.
Notice what's missing: "is it technically possible". It almost always is. The question is whether it's worth owning.
And check that a lighter tool can't do the job. If the change is a number or an on/off switch, a flag or a config value is far less to maintain, and feature flags vs remote config vs CMS helps you tell which. The whole menu, from staged releases to WebViews, is in ways to change a live app without shipping a release.
Go deeper: what server-driven UI is · where it helps and where it hurts · ways to change a live app without a release · flags vs config vs CMS
The renderer is the product
People talk about server-driven UI as if the JSON were the interesting part. It isn't. The JSON is a letter. The renderer decides what the letter means, and it's the part that ships inside every app build, so it's the part you can't change quickly.
Four things take part, and it helps to keep their jobs apart:
- The schema says what a screen can contain. It's a contract.
- The component registry maps each component name in the schema to something the app can draw.
- The renderer walks the description, looks things up in the registry, and produces real UI.
- The host app supplies what the description only names: the actual price for a plan ID, the user's name, what happens on a given action, where "back" goes.
That last one is the line we'd defend hardest. The description says purchase, and your code decides what a purchase is. It says a plan ID, and your code knows the price. Once data or behavior starts leaking into the description, you've begun building a second app inside the first one.
Three decisions deserve to be made on purpose, not by accident.
What happens with a component the app doesn't know. An app on last year's build will meet a component that didn't exist when it shipped. There are three honest options: skip it, draw a fallback the author supplied, or refuse the whole screen. Skipping is friendly but can leave a screen that makes no sense. Refusing is strict but safe. Pick per screen type, write it down, and test it. The caching post has a small example of the refusing kind.
Whether you rebuild or update. The simple renderer throws away the old tree and builds a new one every time the description changes. For a small screen that's fine and easy to reason about. It gets awkward once the screen holds state, such as text someone has half typed into a field. React's docs describe the underlying rule: it keeps a component's state "for as long as it's being rendered at its position in the UI tree", and a key lets you say which item is which instead of relying on order[4]. The lesson carries over to any renderer. If an update can reshuffle nodes, give them stable identities in the schema, or you'll find that a typed answer vanishes when someone edits a headline.
Where the registry stops. The more generic your components, the more the schema starts to look like a programming language. The Flutter team's remote-widgets package is a useful reference for restraint: its README says it is "best suited for interfaces made out of prebuilt components" and that remote libraries are "declarative and not code"[5]. That's one team's package, but the stance is worth borrowing. The registry is a closed list, and adding to it is a code change in the app.
Go deeper: the renderer sketch in what server-driven UI is shows skip-unknown in a short sketch.
Fetching, caching and going offline
Once the screen lives on a server, every copy on a device is fresh, stale but usable, or gone. Our rule: show the first two immediately and refresh behind them. When the copy is gone, a screen that exists to promote something should usually not appear at all rather than appear wrong.
Three consequences are worth knowing before you build anything.
Cache-first means a change reaches people on their next open, not their current one. For most screens that's fine. For a legal notice it isn't, so it gets a short stale limit or a network-first path. Choose the two time limits (how long it's fresh, how long past that it may still be shown) per screen type, not once for the app.
Keep money out of the screen. If a cached screen shows a price, it will eventually show an old one. The fix is structural: the description names a plan, and the app looks up the current price, currency and trial at render time. Layout and wording can be days old without harm. Price can't. It's a design opinion, not a standard, but we can't think of a good argument for putting the number in the cached document.
Decide what a first launch with no network looks like. There's no saved copy yet. Either the build carries a default for the screens that matter, or the screen doesn't appear that time. Promotional screens should usually skip. Required ones, like a legal notice, need the bundled default.
The reasoning, the HTTP headers involved and a small server and client you can run are in the caching post.
Go deeper: Caching remote screens: fresh, stale or gone?
A schema is a promise to every old app
Of everything here, this is what gets teams in trouble a year in. A phone can run a build from months ago. Every component you add, rename or remove is something that build either understands or doesn't.
The habits we'd start with are plain and a bit boring:
- Add, don't rename. A new optional field is safe. A renamed one breaks every app that hasn't updated.
- Say what unknown means. Unknown component, unknown field, unknown action. Each needs a defined behavior, and "crash" isn't one.
- Put a version or hash on every published screen. When someone reports a bug, you want to know which version they saw, not guess.
- Name things by meaning.
color.primaryages better than#3366ff. The Design Tokens Community Group publishes a token format that tooling can share; it's a community group's work, not a W3C standard, and its repository lists version 2025.10 as stable[7]. You don't need that format to get the benefit. Naming a color by role lets a brand refresh happen in one place. - Keep logic out. This is the habit we feel strongest about. The first time someone asks for "show this block only if the user has done X twice", the schema is at a fork. One road adds a small expression language: conditions, then comparisons, then variables. The other keeps the schema as data and puts the decision in code the app owns, or in targeting rules outside the schema. We'd take the second road. An expression language inside a document is code that skips review, has no types and needs its own tests. It also pushes you toward the store-rule questions we mention below.
Two limits on this advice. We're giving you an opinion, and teams with a large, well-staffed platform group do build rich expression layers and make them work. And "keep logic out" means more pressure on whatever does hold the logic. You need somewhere sane for it to live.
Everything the server can send has to be something an old app can survive. If you can't state what an app two versions behind does with a screen, you aren't ready to publish it.
Go deeper: the old-app problem is the second cost in where it helps and where it hurts, and the compatible-server approach is section 8 of ways to change a live app without a release. We don't have a separate post on schema design.
Treat the payload as untrusted, even though it's yours
The server is yours, so why distrust what it sends? Because the path from an editor to a phone has more doors than the server does. An editor account can be taken over. Someone can publish staging content to production. A well-meaning person can paste something strange. A renderer that trusts its input turns each of those into an incident on every device at once.
Our checklist is short:
- Validate against an explicit schema, and reject what doesn't fit. OWASP's input validation guidance recommends defining what the application accepts and rejecting everything else, and warns that trying to "recognize every malicious string" is the wrong approach[1]. It also says to check both syntax (type and format) and semantics (does the value make sense for its use)[1]. A repaired document is a document nobody reviewed, so we prefer refusing over fixing.
- Allowlist components, actions and link schemes. Only names in the registry draw. Only action names the app has code for run. Links open only for schemes you've decided on, such as
https,mailtoandtel, neverjavascript:ordata:. - Treat text as text. Put it in the UI as text, not as markup, unless you've deliberately chosen a small markup subset and escape the rest first.
- Cap size and depth. A document with ten thousand nested stacks can hurt a phone without containing anything malicious.
- Never send code. Not scripts, not expressions that act like scripts. The Flutter package makes the same point from a different angle: its remote libraries can't represent executable closures, and it can only assemble widgets the app already contains[5]. We'd add a practical reason on top of the technical one. Apple's and Google's rules about downloaded code are the kind of thing you don't want to test with your whole app. What server-driven UI is quotes the relevant lines, and we're not lawyers.
- Don't use the client as a gate. Whatever reaches the device is readable by the person holding it. A hidden section in a screen description hides it from the interface, not from them. Enforce access on the server.
The second half of this problem is what a button does. We'd keep the model strict: the schema names an intent, such as purchase, close or open_help, and the app maps each name to its own code. No arbitrary URLs as targets, no function names, no parameters that get evaluated. It costs flexibility. A designer who wants a button that does something new has to ask for an app change. That friction is the price of knowing what every button in the field can do.
We don't have posts on schema validation or action models yet, so this part stays a checklist.
Accessibility belongs to the renderer
On a native screen, each developer adds labels and tests with a screen reader, one screen at a time. On a server-driven screen, the renderer does it once, for every screen. That's a gift if the renderer gets it right and a multiplier if it doesn't, and the person editing the description usually can't patch it.
Android's guidance states the goal in a sentence: users "must be able to understand the content and purpose of each interactive and meaningful UI element within your app", and it asks for a label that explains each element's meaning and purpose, while purely decorative elements get none[6]. In a renderer, that becomes rules:
- Every interactive component draws with a role and an accessible name. An icon-only button with no name is a bug in the description, and the publish step should catch it rather than the renderer guessing.
- Focus order follows reading order. A dialog moves focus in and returns it on close.
- Text scales with the system setting. Don't fix sizes in pixels.
- Motion respects the reduced-motion preference.
- Anything that appears after the screen is already showing, such as an error or a "saved" note, reaches assistive technology without stealing focus. WCAG 4.1.3, a level AA criterion, asks for exactly that: status messages that can be "programmatically determined through role or properties" so assistive technology can present them without receiving focus[2].
- Tap targets are big enough. WCAG 2.5.8 sets a minimum of 24 by 24 CSS pixels for pointer targets, with listed exceptions[3]. Treat that as a floor. We'd build well above it.
We read both WCAG criteria from the W3C's source repository on GitHub, not from the published recommendation page, which our environment couldn't reach. Check the numbers against the current text before you cite them in an audit.
Notice what we've left out: a claim that any renderer conforms to WCAG. Conformance is a statement about a particular screen in a particular state, and you earn it with testing, including manual testing with a screen reader. A renderer can make good results the default. It can't promise them.
We don't have a dedicated accessibility post yet.
Measure what was seen, not what was sent
If you don't ship the UI, you can't assume it was shown. A request for a screen is not a screen on a display, and a screen on a display is not a screen someone looked at.
We'd track these moments as separate events: requested, rendered, visible, interacted, dismissed. A conversion, if the screen leads to one, is its own event, usually recorded by the part of your app that handles payment. That keeps the numbers honest. "Shown" and "seen" differ when a screen renders under a keyboard or off the bottom of the viewport. And a screen that fails to render should show up as a drop between requested and rendered, not as silence.
Three details trip people up. Events can arrive twice, out of order, or never, especially from a phone that went offline, so design the analysis to survive that. Keep personal data out of event payloads. And write down what "visible" means for you (how much of it, for how long) before anyone looks at a dashboard, because the definition changes the result.
Measurement also connects to how you'll read experiments later, which is a topic of its own. For now: if you can't tell a screen that didn't render from a screen nobody opened, fix that first.
The longer version is tracking a UI you don't ship.
Test it like a contract
Server-driven screens move errors from compile time to run time. A bad edit is a live bug. Testing has to move with them, and we'd think in three layers.
Is the document valid? Check every screen against the schema before it can be published. This catches typos, missing labels and unknown components in seconds, and it's the highest-value test on this page.
Does it draw the way you expect? Keep a set of fixtures, which are real example documents covering every component and the awkward combinations, and render them. Snapshot comparison helps, but pixel-exact images are brittle: text rendering differs between machines, so a diff can fire for no real reason. Pin the environment, use tolerances, and be ready to accept that some diffs are noise. If you support several platforms, running the same fixtures on each is the cheapest way to find drift.
Does an old client survive a new document? This is the layer people skip. Keep the last few renderer versions around, feed them the newest documents, and assert that they do something defined: draw, fall back, or refuse. It's the test that turns "we think old apps are fine" into a fact.
We haven't written up fixtures or golden files in depth, and this section is a sketch. The point is the shape: validate the document, render it, and check the old client against the new document.
Run it like a release process
Server-driven UI separates deploying the renderer from releasing a screen, which is the idea in deploy is not release. That is the benefit, and it's also the danger, because the second decision happens faster and with fewer eyes.
What we'd want in place before anyone besides engineers can publish:
- A preview that runs the real renderer. Not a mock-up. If the preview and the app disagree, the preview teaches people to distrust it.
- A publish gate that runs the same checks as the schema test. Drafts can be messy. Published screens can't be invalid.
- Versions you can go back to. Every publish creates a version, and "go back to the previous one" is a button, not a procedure.
- A named owner and a log. Who published what, when, and which version a given device drew.
- A way to turn a screen off. For a promotional screen, rendering nothing is the safest state, as the caching section argued, so make "show nothing" an easy move.
- Staged exposure when it matters. Show a new version to a small share first, watch, widen. The mechanics are the same as for flags, so the flags post applies, including the part about giving every rollout an end.
You can skip most of this for a low-stakes screen edited by two engineers who sit next to each other. The more people who can publish, and the more money flows through the screen, the more of it you need. It's cheaper to add the gate before the first incident than after.
A sensible way to start
If we were starting this week, we'd pick one region on one screen that the business keeps asking to change. We'd define five or six components, no more, and write down what an old app does with an unknown one. We'd keep prices and actions in app code, validate before publish, cache with a short stale limit, and ship it to a small share first. Then we'd wait a few weeks and see whether anyone used the freedom. If nobody did, we'd have learned that cheaply. If people did, the next component is a request with a reason attached.
That's a plan from reasoning, not from a history of doing it for years, and your constraints may differ. The decision table at the top is the part we'd defend most.
Sources
- OWASP Cheat Sheet Series, Input Validation (source on GitHub) (opens in a new tab)github.com · accessed
- React docs, Preserving and Resetting State (source on GitHub) (opens in a new tab)github.com · accessed
- Android Developers, Accessibility principles (opens in a new tab)developer.android.com · accessed
- Design Tokens Community Group, official repository (opens in a new tab)github.com · accessed


