One UIConfig, Four Runtimes: Building a Production Server-Driven UI Across iOS, Android, React, and React Native

Server-driven UI is having another moment. Airbnb wrote a famous post about Epoxy years ago. Lyft has talked about theirs. Every six months someone on Hacker News rediscovers the idea and writes “what if your screens were JSON?”

Most teams that try it produce a thin JSON-to-component mapper that handles 80% of screens and dies on the other 20%. The 20% is where the real product lives: forms with conditional fields, multi-step workflows with stepper state, expressions that depend on data the user just entered, actions that chain success/failure callbacks. The 20% is also where the marketing demo videos cut away.

The client in this story is a B2B SaaS platform whose customer-facing app drives a workflow with dozens of interactive screens per session, on phones held in dirty hands at a customer’s lot. Shipping changes through the App Store and Play Store on the conventional cadence had become a competitive liability. We built a real server-driven UI runtime, and we shipped it on four platforms: native iOS (SwiftUI), native Android (Jetpack Compose), React, and React Native.

Same UIConfig contract. Same expression engine. Same operations model. Four implementations, kept in lockstep through a parity test suite.

Here’s the architecture and the four traps that almost killed it.

What “server-driven UI” actually means when you mean it

Most “SDUI” frameworks I have seen amount to: a JSON tree of { "type": "Button", "props": { "title": "Submit" } }, parsed into native components. That works fine until you need a button whose enabled state depends on a checkbox three screens ago.

A real SDUI runtime needs:

Cut any one of those and you have a brochure renderer, not a UI runtime.

The UIConfig contract

The contract is the spine. Everything else implements it. We wrote it once, in TypeScript types, and the four runtimes consume the same JSON.

{
  "initialScreen": "home",
  "screens": [
    {
      "type": "Screen",
      "route": "home",
      "title": "Inspections",
      "body": [
        { "type": "@platform/Header", "text": "Today" },
        {
          "type": "@platform/Button",
          "title": "Start inspection",
          "onPress": {
            "type": "NavigateAction",
            "screen": "checkout",
            "params": { "assetId": "{{ selectedAsset.id }}" }
          }
        }
      ]
    },
    {
      "type": "OperationConfig",
      "uuid": "op-checkout-v3",
      "operation": "@platform/MultiScreenOperation",
      "route": "checkout",
      "steps": [
        { "type": "Screen", "route": "vehicle-info", "body": [...] },
        { "type": "Screen", "route": "damage-photos", "body": [...] },
        { "type": "Screen", "route": "signature", "body": [...] }
      ]
    }
  ],
  "stores": [
    { "key": "user", "schema": "User", "persisted": true }
  ],
  "data_sources": [
    {
      "key": "selectedAsset",
      "produces": "Asset",
      "endpoint": "/api/v3/assets/current",
      "dataPath": "data.asset"
    }
  ]
}

Delivery is unglamorous. A small config service signs the JSON, versions it per client build, and serves it with caching headers. The platform clients fetch on cold start and on a periodic refresh. On the client, the entry point is a single hook that hydrates the runtime from the fetched config.

// React / React Native client entry — same hook on both.
import { useConfig, SDUIRuntime } from "@platform/sdui-react-sdk";

export function App({ product, buildVersion }: AppProps) {
  const { config, status } = useConfig({ product, buildVersion });

  if (status === "loading") return <SplashScreen />;
  if (status === "error" || !config) return <ConfigFallback />;

  return <SDUIRuntime config={config} />;
}

SDUIRuntime is the part that does the real work: it walks the screens array, registers the routes with the platform navigator (React Router on web, React Navigation on React Native), and hands each screen body to the renderer. The same component is the entire integration surface on web and on mobile. Once it is mounted, the app does not know it is server-driven.

The interesting work is everything that happens inside that runtime.

Trap 1: The expression engine

When we wrote the v1 expression engine we used regex substitution. Replace {{ foo }} with the value of foo from a context object, return the resulting string. It worked for ninety-five percent of cases.

The five percent broke us. A button’s disabled property wanted a boolean, not the string "true". A list’s count wanted a number. An interpolated label like "Welcome, {{ user.name }}" wanted a string. Single-expression bindings and interpolated bindings are different evaluation modes, and the regex did not know which it was in.

The v3 engine separates them explicitly. A node whose value is exactly {{ expr }} returns the raw evaluated value. A node containing one or more expressions inside other text always returns a string. The parser tags each binding at config load time so the runtime never has to guess.

// The evaluator core, in the shape it ships in @platform/sdui-react-sdk.
type Binding =
  | { kind: "raw"; expr: string }
  | { kind: "interpolated"; parts: Array<{ kind: "literal"; text: string } | { kind: "expr"; expr: string }> };

export function evaluate(binding: Binding, ctx: EvalContext): unknown {
  if (binding.kind === "raw") {
    return evalExpression(binding.expr, ctx);
  }
  return binding.parts
    .map(part => part.kind === "literal" ? part.text : String(evalExpression(part.expr, ctx)))
    .join("");
}

The expressions themselves run inside a sandbox using new Function on web, JavaScriptCore on iOS, and a hand-rolled walker on Android (Compose plus Kotlin reflection turned out to be cheaper than embedding V8 just for this). The grammar is intentionally limited: dot-path access, namespace prefixes, comparison operators, ternaries, and a handful of safe built-ins. No assignment, no loops, no method calls into native code.

The namespace prefixes are the part that took the longest to get right. @operation.vehicleId reads from the active operation’s data dictionary. @params.assetId reads from the params passed by the action that navigated here. @store.user reads from a persistent store. Bare identifiers inside an operation context implicitly bind to @operation. We had to write a spec document explaining all four cases because the implementations kept diverging.

Trap one was assuming we could ship the expression engine last. We could not. It is the spine of every binding in the config; cutting corners there shows up as four-platform-divergent bugs in production.

Trap 2: Operations as state machines

The first version of operations treated them as “a sequence of screens.” The second version treated them as “a screen with sub-screens.” The third version treated them, correctly, as state machines.

An operation has:

The persistence requirement is the part most teams underestimate. A field tech driving between sites takes a phone call mid-inspection. The operation needs to survive the app being backgrounded for thirty minutes, the device rebooting, and the network changing. We serialize the entire operation state on every step transition and rehydrate it on cold start.

{
  "currentStepIndex": 2,
  "totalSteps": 5,
  "operation": {
    "vehicleId": "v_9X1KQ",
    "mileage": 84211,
    "photos": ["snap_91f...", "snap_a40..."]
  },
  "params": { "assetId": "a_177" }
}

Trap two was treating operations as a UI pattern. They are a transactional pattern. Their state machine survives outside the renderer, and the renderer is just one consumer of it.

Trap 3: Component registry per platform

The component identifier in the config is something like @platform/Button. The platform-specific registry maps that identifier to a native implementation: a SwiftUI view on iOS, a Composable on Android, a React component on web and React Native.

Three rules came out of pain:

  1. Components are versioned in the identifier. Not in a separate field. @platform/Button and @platform/Button@2 are different components. This is so a config can target a specific component version, and the registry can refuse to load the config if the version is not implemented yet.
  2. Registries declare their platform constraints. A component registered with platform: "ios" is invisible to the Android resolver. A component registered with platform: "*" is universal. This is so we can ship platform-specific components without polluting the cross-platform path.
  3. Resolution is lazy and explicit. The runtime resolves a component the first time it is rendered, caches the resolution, and throws a typed error if it cannot find an implementation. Silent fallback to a generic component is the worst behavior we tried; it produces an app that looks like it works and behaves wrong.

Trap three was wishing we could keep the registry implicit. We could not. The registry is the contract between the config and the runtime, and contracts that are not explicit become bugs.

Trap 4: Multi-runtime parity

Four runtimes mean four chances to drift. The first time we noticed, the iOS team had implemented a date picker with a different default value than the Android team. The product manager spent three days arguing with two engineers about which one was correct. Both were correct on their own platform. Neither was correct against the contract.

We solved this by writing the contract first, in TypeScript types and JSON Schema, and treating any platform-specific behavior as a bug against the contract. Then we wrote a parity test suite: a corpus of UIConfig fixtures, each paired with an expected render snapshot and an expected post-action state. The suite runs against all four runtimes in CI. Drift produces a failed snapshot, not a Slack argument.

// fixtures/checkout_operation/expected_state_after_step_2.json
{
  "currentStepIndex": 2,
  "operation": {
    "vehicleId": "v_9X1KQ",
    "mileage": 84211
  }
}

Each runtime has a headless test harness that loads the fixture, executes the scripted action sequence, and emits the final state as JSON. For the React and React Native runtimes we use @testing-library/react and @testing-library/react-native, driving the action sequence through the runtime’s own action dispatcher:

import { renderRuntime } from "@platform/sdui-react-sdk/testing";

test("checkout_operation reaches expected state after step 2", async () => {
  const harness = renderRuntime({ configFixture: "checkout_operation" });
  await harness.dispatch({ type: "NavigateAction", screen: "checkout" });
  await harness.fillScreen("vehicle-info", { vehicleId: "v_9X1KQ", mileage: 84211 });
  await harness.completeStep();

  expect(harness.snapshotOperationState()).toMatchFixture(
    "expected_state_after_step_2.json"
  );
});

CI runs the same fixture against all four runtimes (the iOS and Android harnesses load the JSON the same way through their own native equivalents) and diffs the outputs. If any of the four diverge, the build fails and points at the responsible runtime.

The corpus is now a few hundred fixtures. It catches more bugs than the manual QA pass ever did, and it scales sublinearly in cost (writing a new fixture is cheap; writing four platform tests by hand is not).

Trap four was thinking parity is a process problem. It is a tooling problem. Process keeps the runtimes converging; tooling keeps them converged.

What we would do differently

Results

The takeaway

Server-driven UI is not a JSON renderer. It is a contract between a config and a runtime, with seven moving parts (rendering, expressions, state, navigation, actions, operations, data loading) that all have to work or none of them do. If you skimp on the expression engine or the operations model you end up with a system that handles eighty percent of screens and embarrasses you on the other twenty. Build the contract first, build the parity tests early, and treat the registry as an explicit boundary rather than a convention.

This was one project in a body of similar multi-platform work. If you are mid-way through your own SDUI effort and the runtime is starting to feel like four codebases pretending to be one, the parity-tests-and-explicit-contract pattern is what got us out.