JUN 26, 202610 min read

Server-Driven UI in Production: One JSON Contract, Two Repos, Zero-Release Screen Changes

Overview

A native app release moves in days: build, submit, wait for review, hope nothing regresses. Product iteration wants to move in hours. Server-Driven UI (SDUI) closes that gap by moving the description of a screen out of the compiled app and onto the wire. The backend service decides what a screen contains and sends it as JSON; the client app reads that JSON and renders it. This piece walks through a concrete shape of SDUI: a Python/Flask backend service that emits typed JSON sections, and a React Native + Expo client app that maps each section's type to a component through a registry.

Context

Server-Driven UI means the server, not the shipped app binary, decides the structure of a screen. Instead of hardcoding "the home screen has a header, then a grid, then a button" inside the app, the backend sends a list that says exactly that, as data. The client app's only job is to turn each piece of that data into a rendered component.

The core problem this solves is a cadence mismatch. A mobile app is gated by app-store review: a one-line copy tweak or a reordered screen can take one to two weeks to reach users, and iOS, Android, and web can drift out of sync while you wait. Product teams want to test ideas, reorder content, and roll changes back the same day. SDUI resolves the tension by drawing a line: layout and content live on the server (change them anytime), while the catalog of kinds of blocks the app can render lives in the client (change them on the normal release cadence). As long as a change reuses block kinds the app already understands, it ships as data.

The screen, visualized

Before the contract, it helps to see what "a screen is data" actually means. Take a learning app's home screen: a progress header at the top, a grid of subject tiles, an upgrade call-to-action, and a streak row. To a user it looks like one designed page. To SDUI it is an ordered list of typed blocks, nothing more.

Each labeled block on the right is one typed section. The screen is just the order of those types plus the data each one carries. Reorder them, drop one, or insert another, and you have a different screen, without changing a single rendering rule.

+----------------------------------------+
|  Welcome back, Sara        progress 62% |   <- progress_header
|  [#################-----------]         |
+----------------------------------------+
|  Your subjects                          |
|  +----------+  +----------+  +--------+  |   <- subjects_list
|  |  Math    |  | Science  |  | Arabic |  |
|  |  72%     |  |  40%     |  |  10%   |  |
|  +----------+  +----------+  +--------+  |
+----------------------------------------+
|  Unlock everything with Pro     [ Go ]  |   <- cta / paywall
+----------------------------------------+
|  Streak: 5 days   o o o o o . .         |   <- streak
+----------------------------------------+

Before and after

The payoff is easiest to see by contrasting how a screen change travels through each model. In the hardcoded world, the screen lives inside the app, so every change is a code change that must pass through app-store review before any user sees it, and each platform is edited and shipped separately. In the SDUI world, the screen is JSON the server controls, so a change is an edit to that JSON (or a flag flip) and it is live in minutes through one shared contract.

        BEFORE: screen hardcoded in app        AFTER: screen is JSON {order, sections}
   +-----------------------------------+   +-----------------------------------+
   |  product change                   |   |  product change                   |
   |        |                          |   |        |                          |
   |        v                          |   |        v                          |
   |  edit app code (iOS, Android, web)|   |  edit JSON / flip a flag          |
   |        |                          |   |        |                          |
   |        v                          |   |        v                          |
   |  app-store review (1-2 weeks)     |   |  live in minutes                  |
   |        |                          |   |        |                          |
   |        v                          |   |        v                          |
   |  platforms drift out of sync      |   |  one contract, all platforms      |
   +-----------------------------------+   +-----------------------------------+

Scenario: add a promo banner above the subjects

Concretely, suppose product wants a promo banner to appear above the subjects grid on the home screen. The two models handle this very differently.

Before SDUI, a promo banner is a new native widget. Someone builds it on iOS, again on Android, and again on web, wires it into the home screen layout, runs it through QA on each platform, and waits for store review. Call it one to two weeks. A/B testing it means shipping both variants in the binary and gating them client-side, which is awkward and slow to change.

With SDUI, the promo banner is just another section type. Assuming promo_banner is already registered in the client app (it was shipped in some earlier release), adding it is a server-side edit: insert a promo_banner entry into the screen's order, give it the data it needs, and it is live instantly. Running it as an A/B test is a feature flag that decides whether the section is included for a given cohort, no deploy on either side.

   BEFORE                                AFTER
   build native widget x3 platforms      add "promo_banner" to JSON order
   wire into home layout                 type already registered in app
   QA each platform                      live instantly
   store review ~1-2 weeks               A/B via a server flag

The contract: one type, two sides

SDUI only works if both sides agree on the wire format and keep agreeing as the product evolves. The unit of that agreement is the section envelope: a small JSON object with a type discriminator and whatever extra fields that type needs. The backend service declares the type; the client app keys off it. That single string is the entire integration surface between the two systems.

At runtime the flow is a plain HTTP request for a screen. The client app asks the backend for a screen by name; the backend service builds an ordered list of typed sections and returns it; the client walks the list and dispatches each section's type to a component.

A real-ish section envelope looks like this:

{
  "type": "generic_content_section",
  "display_type": "hero",
  "content_level": "subject",
  "action": { "kind": "open_subject_tree" },
  "items": [
    { "id": "1", "title": "One", "cover_url": "a.png" },
    { "id": "2", "title": "Two", "cover_url": null }
  ]
}

Note what the envelope carries beyond type: a display hint (display_type), data (items), and an action describing what happens on interaction. The client does not infer any of this; it reads it straight off the JSON.

The client: a registry, not a switch

On the client app, rendering is a registry lookup, not a growing switch statement. A registry maps each type string to a component, and a single renderer resolves the component or skips gracefully.

const sectionRegistry: Record<string, SectionComponent> = {
  progress_header: ProgressHeaderSection,
  subjects_list: SubjectsListSection,
  paywall: PaywallSection,
  cta: CtaSection,
  // ...one entry per section type
};

function SectionRenderer({ sectionKey, section, ctx }: SectionProps) {
  const Component = sectionRegistry[section.type];
  if (!Component) {
    if (__DEV__) console.warn(`[SDUI] Unknown section type "${section.type}" - skipping.`);
    return null; // graceful-skip, never crash
  }
  return <Component sectionKey={sectionKey} section={section} ctx={ctx} />;
}

The key property is graceful-skip. If the backend emits a type the installed app bundle has never heard of, the renderer logs one dev-only warning and renders nothing. That means JSON and bundle can be out of sync, an older app meeting a newer backend, without crashing a screen. The backend can even pre-deploy data for a new type, and it lights up the moment a client release that registers that type lands. Components read props directly off the JSON, so there is no separate deserialization step: the envelope is the props.

A/B testing by changing JSON

Once screens are data, experimentation stops being a code branch and becomes a content decision. Sections and whole screens are gated by server-side feature flags. The backend resolves each flag from a database-backed store with a static defaults fallback, so an unset or unknown flag still has a defined value. A section carries a gate flag; the section builder evaluates that gate before deciding whether to emit the section, or which variant of it to emit.

To run an A/B test, point a screen at two candidate sections behind the same flag and flip the flag server-side for a cohort. No deploy on either system. Want to test a new paywall against the current one, or a reordered home screen? Change the JSON the builder emits and flip the flag. The client app renders whatever arrives.

Design principles at work

This architecture is a clean instance of several familiar principles, each tied to a concrete mechanism:

  • Single Responsibility (SRP): each section component renders exactly one block type and nothing else. SubjectsListSection knows how to draw a subjects grid; it knows nothing about paywalls or streaks.
  • Don't Repeat Yourself (DRY): there is one registry and one render path instead of N bespoke screen-builders, and one shared contract instead of two divergent definitions of "a screen."
  • Inversion of Control / Dependency Injection (IoC/DI): the app never calls section components directly. It hands control to the registry, which selects the component. The data source (which screen JSON to fetch) and the gate resolver (how flags evaluate) are injected dependencies, so they can be swapped in tests or per environment.
  • Publish/Subscribe (event-driven): section components do not navigate or mutate global state themselves. They emit events (for example, a CTA emits a complete event), and a host runner subscribes to those events and routes them. Sections stay decoupled from app navigation.
  • Model-View-Controller (MVC): the JSON screen is the model, the section components are the view, and the registry plus host runner are the controller that maps model to view and dispatches actions.

What product and engineering get

  • Velocity: most screen changes ship as data, not as an app-store submission and review wait.
  • No release for registered types: reordering, reparameterizing, or feature-flagging sections of already-registered types never needs a client release.
  • Safe rollback: A/B tests and rollouts are flag flips on existing types, reversible instantly with no rollback build.
  • Fewer drift bugs: one shared contract, agreed by both systems, removes the "frontend and backend disagree" failure mode, and unknown types degrade quietly instead of crashing.