- Marco Ordoñez/
- Posts/
- Server-Driven UI in React Native: an old idea, a modern playbook, and what actually works/
Server-Driven UI in React Native: an old idea, a modern playbook, and what actually works
Most people think Server-Driven UI (SDUI) is something “new” from the mobile era, but the core idea is actually old: the server sends a description of the UI, and the client renders it.
This came up in a meeting I had recently, and it triggered me to write it down because these topics are usually mentioned fast and left half-explained.
Before we go deeper: this is not “the one true implementation”. Think of this as a playbook. Take what fits your team, your constraints, and your product stage.
Airbnb helped make this approach very visible in modern mobile engineering, but the concept existed long before that in server-rendered web pages, XML-based UI systems, and enterprise clients that already separated “screen definition” from “client rendering”.
So yes, SDUI is modern, but also old-school at the same time.
Like map from functional programming, old idea, still everywhere. Same with
monads: people say “I never use them”, then chain Promise.then(...) all day.
First, what is SDUI in simple terms? #
In plain language:
The backend sends a screen contract. The app interprets that contract and renders components dynamically.
Instead of hardcoding every screen release-by-release, you hardcode a renderer and a component registry, then the server decides what to show.
That gives us:
- Faster UI iterations without forcing a full app release for every change.
- Better A/B testing and feature rollout control.
- Shared screen behavior across iOS/Android/Web (if all support the contract).
But also gives us:
- More complexity in contracts and validation.
- Potential runtime failures if contracts are not stable.
- Harder debugging if your architecture is weak.
A quick history before Airbnb #
SDUI-like patterns existed before mobile apps became dominant:
- Traditional web apps were already server-driven in many flows.
- Enterprise XML-driven UI frameworks separated structure from rendering.
- Dynamic form engines in banking/insurance/government did similar things for years.
What Airbnb did was make a strong modern mobile narrative around this strategy: versioned contracts, client-side renderers, and safer rollout patterns in high-scale apps.
Where SDUI fits in React Native #
React Native is actually a good match for SDUI because:
- RN already renders from a component tree.
- We can map remote component definitions to local React components.
- JavaScript/TypeScript makes parsing and transformations flexible.
The key is to avoid “stringly typed chaos”.
SDUI in RN needs a layered architecture if you want it to survive production.
One architecture that works in real teams #
The important layers:
- Transport DTO
Raw data exactly as API sends it. - Validation
Runtime schema validation before rendering. - Mapper / Transformation layer
Converts DTO into strongly typed UI models. - Renderer + Registry
Safe map fromcomponentType-> React component.
If you skip the mapper layer, you usually end up mixing backend contracts with UI internals, and things get messy very quickly.
Example contract (JSON) #
{
"screenId": "home_v3",
"version": 3,
"components": [
{
"id": "welcome_title",
"type": "text",
"props": {
"value": "Welcome back Marco",
"variant": "h1"
}
},
{
"id": "promo_banner",
"type": "banner",
"props": {
"title": "New premium plan",
"subtitle": "Try it free for 30 days",
"cta": {
"label": "Start trial",
"action": {
"type": "navigate",
"payload": {
"route": "Subscription"
}
}
}
}
}
]
}
DTO + validation (TypeScript) #
import { z } from "zod";
const ActionDtoSchema = z.object({
type: z.enum(["navigate", "deeplink", "track"]),
payload: z.record(z.any()),
});
const ComponentDtoSchema = z.object({
id: z.string(),
type: z.string(),
props: z.record(z.any()),
});
export const ScreenDtoSchema = z.object({
screenId: z.string(),
version: z.number(),
components: z.array(ComponentDtoSchema),
});
export type ScreenDto = z.infer<typeof ScreenDtoSchema>;
This layer is just transport. Do not render directly from this.
Domain UI model + mapper layer #
const TEXT_VARIANTS = ["h1", "h2", "body"] as const;
type TextVariant = (typeof TEXT_VARIANTS)[number];
type TextBlock = {
kind: "TextBlock";
id: string;
value: string;
variant: TextVariant;
};
type BannerBlock = {
kind: "BannerBlock";
id: string;
title: string;
subtitle?: string;
cta?: {
label: string;
action: {
type: "navigate" | "deeplink" | "track";
payload: Record<string, unknown>;
};
};
};
type UnknownBlock = {
kind: "UnknownBlock";
id: string;
originalType: string;
};
export type UIBlock = TextBlock | BannerBlock | UnknownBlock;
function logSduiContractIssue(event: {
screenId: string;
componentId: string;
issue: string;
received: unknown;
}) {
// send to Sentry/Datadog/NewRelic with tags
// logger.warn("sdui.contract_issue", event);
}
function isTextVariant(value: unknown): value is TextVariant {
return TEXT_VARIANTS.includes(value as TextVariant);
}
function resolveTextVariant(
value: unknown,
context: { screenId: string; componentId: string },
): TextVariant {
if (isTextVariant(value)) return value;
logSduiContractIssue({
screenId: context.screenId,
componentId: context.componentId,
issue: "invalid_text_variant",
received: value,
});
return "body";
}
export function mapScreenDtoToUIBlocks(dto: ScreenDto): UIBlock[] {
return dto.components.map((component) => {
switch (component.type) {
case "text":
return {
kind: "TextBlock",
id: component.id,
value: String(component.props.value ?? ""),
variant: resolveTextVariant(component.props.variant, {
screenId: dto.screenId,
componentId: component.id,
}),
};
case "banner":
return {
kind: "BannerBlock",
id: component.id,
title: String(component.props.title ?? ""),
subtitle: component.props.subtitle
? String(component.props.subtitle)
: undefined,
cta: component.props.cta
? {
label: String(component.props.cta.label ?? "Open"),
action: {
type: component.props.cta.action?.type ?? "track",
payload: component.props.cta.action?.payload ?? {},
},
}
: undefined,
};
default:
return {
kind: "UnknownBlock",
id: component.id,
originalType: component.type,
};
}
});
}
This mapper is one of the most important pieces in SDUI.
It protects your app from backend instability.
The fallback strategy is a team decision, not a universal rule.
Some teams fallback to body, others hide the block, and others show an error UI.
Whichever strategy you choose, log it with enough context (screenId, componentId, bad value) so the backend and mobile teams can detect and fix contract drift quickly.
Renderer + registry in React Native #
import React from "react";
import { Text, View, Pressable } from "react-native";
function TextBlockView({ block }: { block: Extract<UIBlock, { kind: "TextBlock" }> }) {
if (block.variant === "h1") {
return <Text style={{ fontSize: 28, fontWeight: "700" }}>{block.value}</Text>;
}
if (block.variant === "h2") {
return <Text style={{ fontSize: 22, fontWeight: "600" }}>{block.value}</Text>;
}
return <Text style={{ fontSize: 16 }}>{block.value}</Text>;
}
function BannerBlockView({
block,
onAction,
}: {
block: Extract<UIBlock, { kind: "BannerBlock" }>;
onAction: (a: NonNullable<BannerBlock["cta"]>["action"]) => void;
}) {
return (
<View style={{ padding: 16, borderRadius: 12, backgroundColor: "#F3F4F6" }}>
<Text style={{ fontSize: 18, fontWeight: "600" }}>{block.title}</Text>
{block.subtitle ? <Text style={{ marginTop: 6 }}>{block.subtitle}</Text> : null}
{block.cta ? (
<Pressable
onPress={() => onAction(block.cta!.action)}
style={{ marginTop: 12, backgroundColor: "black", padding: 10, borderRadius: 8 }}
>
<Text style={{ color: "white" }}>{block.cta.label}</Text>
</Pressable>
) : null}
</View>
);
}
export function ScreenRenderer({
blocks,
onAction,
}: {
blocks: UIBlock[];
onAction: (action: { type: string; payload: Record<string, unknown> }) => void;
}) {
return (
<View style={{ gap: 12 }}>
{blocks.map((block) => {
switch (block.kind) {
case "TextBlock":
return <TextBlockView key={block.id} block={block} />;
case "BannerBlock":
return <BannerBlockView key={block.id} block={block} onAction={onAction} />;
default:
return null; // UnknownBlock fallback
}
})}
</View>
);
}
Notice the important part: unknown blocks do not crash the app.
Action handling pattern (the layer most teams forget) #
When actions come from server, handle them through an action dispatcher instead of direct navigation calls everywhere.
type UIAction = {
type: "navigate" | "deeplink" | "track";
payload: Record<string, unknown>;
};
export function dispatchUIAction(action: UIAction) {
switch (action.type) {
case "navigate":
// navigation.navigate(...)
return;
case "deeplink":
// Linking.openURL(...)
return;
case "track":
// analytics.track(...)
return;
}
}
This keeps business behavior centralized and testable.
JSON vs Protobuf vs other options #
This question appears all the time.
JSON #
- Pros: easy to inspect, easy to debug, fast iteration.
- Cons: bigger payloads, weaker contracts unless validation is strict.
Great for first version and quick evolution.
Protobuf #
- Pros: compact binary format, strong schema contracts, backward/forward compatibility if done correctly.
- Cons: more tooling, more complexity, harder manual debugging than JSON.
Great when payload size/performance and strong contracts are top priorities.
GraphQL #
- Pros: flexible field selection, strong typing from schema, good for client-specific queries.
- Cons: UI contracts can become too generic if not disciplined.
Good when your company already has a strong GraphQL platform.
Practical take (what I would do) #
For many teams:
- Start with JSON + strict runtime validation.
- Add schema versioning early.
- Move hot paths to Protobuf only when needed by scale/performance.
Techniques that really help in SDUI projects #
These are the habits that usually separate fragile SDUI projects from stable ones:
- Schema versioning
- Support
versionin screen contracts. - Keep compatibility window for old app versions.
- Support
- DTO + mapper + domain model
- Never bind UI directly to transport payload.
- Fallbacks for unknown components
- Fail soft, not hard.
- Feature flags and kill switches
- Disable problematic component types remotely.
- Observability
- Log render failures by
screenId,version,componentType.
- Log render failures by
- Contract tests
- Backend emits payload fixtures.
- App validates and snapshots rendering behavior.
- Server-side guardrails
- Validate contracts before shipping them to clients.
Common mistakes #
- Treating SDUI as “just dynamic JSON”.
- No transformation layer.
- No versioning strategy.
- Ignoring offline/cache behavior.
- Sending too much logic to the server and turning the app into a thin browser.
SDUI should drive structure and configuration, not every tiny UI behavior.
When should you use SDUI? #
Good fit:
- Experiment-heavy products (growth, onboarding, paywalls).
- Multi-platform apps with repeated UI patterns.
- Teams that need fast iteration without store approval bottlenecks.
Not a great fit:
- Small apps with very stable UI.
- Teams without backend/platform maturity.
- Cases where all screens are highly native/custom animations.
Links #
- Airbnb Engineering - A Deep Dive into Airbnb’s Server-Driven UI System
- Protocol Buffers Documentation
- Zod
Conclusions #
SDUI is old in concept and modern in execution.
In React Native it works very well if you treat it as an architecture problem, not as a shortcut. A practical baseline is:
- Strong contracts.
- DTO + mapper + domain UI model.
- Safe renderer with fallbacks.
- Versioning and observability from day one.
If you put these pieces in place, SDUI can give you product speed without turning your codebase into chaos.