How to Server Driven UI? (and Why?)
November 13th, 2026 | 19 mins readShould the smallest change require a redeployment?
Act I: Why?
Recently, I had the opportunity to handle a task as trivial as it gets: changing the label of an element in an application's interface. One line of code, in the preferences management screen. Easy, you might say? Except the impact goes well beyond that one line...
The application is available on mobile and published on the stores of several platforms, so the update takes much longer than expected. Store compliance reviews impose their own delays. And once the new version is published, nothing guarantees that every user will install it quickly, or even that they will ever install it...
You have probably been in this kind of situation. At its core, the question is simple: "Why should a content change have to wait for an end-to-end redeployment?"
This is the problem that the Server Driven UI pattern (SDUI, for those in the know) tries to solve. Before we get to the solution, let's take the time to understand the stakes.
One Interface, Described Several Times
In a typical organization, a screen exists in several copies: the Web version (Angular, React, Vue...) and the mobile version (Swift for iOS and/or Kotlin for Android). Each version has its own code, its own components, and its own display rules.
As a result:
- Screens end up diverging, even when the business requirement is identical;
- A fix has to be made several times, by different skill sets, and can be forgotten;
- Redeployment cycles are not synchronized: a Web version can be published in a few minutes, while a mobile version takes several days.
NB: This last point is often the most painful. The Web can be fixed today, the mobile app can't!
The Promise of SDUI
The concept consists of moving part of the decision from the client to the server. The server no longer only returns business data: it also describes what should be displayed and which actions are possible. This brings us closer to the HATEOAS constraint, at the top of Richardson's maturity model. The client knows how to display known components and trigger the actions it is told about.
Concretely, this makes it possible to:
- Change a screen without redeploying the application: the label changes on the server side, and clients see it on their next request;
- Control the composition of the interface remotely: an element can be hidden or disabled depending on the state of the account, without the client knowing the business rule;
- Keep the platforms consistent: one description of the screen, several visual renderings.
NB: This promise has a precise limit. The server can change what the client knows how to display, but it cannot introduce a new type of component. A new Design System component requires a redeployment, because no client knows how to display it yet. This is the main pitfall of SDUI.
The Cost of SDUI
Server Driven UI is not a magic solution. It brings real constraints:
- A schema to evolve: adding a field is easy, removing one is risky, because "outdated" clients may still need it;
- A component rendering engine to maintain: every platform must know how to display every component in the schema;
- A stronger network dependency: an entire screen may go through the server, with the latency (and the offline cases that come with it);
- More complex debugging: a display bug can come from the server, the schema, or the client, and you need to know where to look ๐
Limitations
Server Driven UI is not suited to every project. If your application targets only one platform, or if your APIs are stable, a standard JSON API is more than enough. Adding a dynamic schema to a project that doesn't need one is over-engineering.
For example, a highly interactive application, such as a collaborative text editor, gains little from it, since every interaction would have to go through the server.
In short, SDUI addresses a specific problem: separating content changes from the redeployment cycle. If your application is not in that situation, there's no point adding this layer of complexity ๐
Act II: Theory
In a traditional application, the client carries everything: the screens, their structure, their display rules, and how they are updated. The server merely provides raw data, and the client decides what to do with it.
With SDUI, this split changes. The server describes what should be displayed, where it should be displayed, and which actions the user can trigger. The client does not know the screen in advance. It only knows how to display a finite set of components and trigger the actions the server has declared.
NB: The real boundary is not between "data" and "interface", but between what is decided and what is executed. According to the Server Driven UI pattern, the structure of the screen is decided by the server.
The SDUI Schema
The server responds with a JSON document that describes a complete screen. Here is an example of the shape the client receives for a preferences screen:
{
"version": 1,
"screen": "preferences",
"data": {
"darkMode": false,
"emailNotifications": true,
"pushNotifications": true
},
"ui": {
"type": "stack",
"children": [/* ... */]
},
"actions": {
"updatePreferences": {
"method": "PATCH",
"href": "/api/preferences/a1b2c3d4"
}
}
}
Each property has a distinct role:
versionindicates the version of the schema. The client checks it before rendering anything, in order to refuse a format it doesn't know how to read;screenidentifies the screen. It lets the client know the display context, for example for tracking or cache management;dataholds the state, that is, the current values. It contains no presentation information;uidescribes the component tree, their structure and their content, without carrying any state value;actionsdeclares the possible actions, with their HTTP method and their address. The client does not know them in advance.
NB: Separating
dataanduiis the most important design choice in the schema. The server can change the presentation without touching the values, and vice versa. The client understands that state and structure evolve independently.
A Component Registry
The ui tree is made of nodes. Each node carries a type field, which identifies it as an element of a component registry that the client knows in advance: a container, a title, a text, a form field, and so on. The server chooses the nodes and their content, but it cannot add a new type.
The type acts as a discriminant. It tells the client which component to display. The client therefore doesn't need to know the structure of the screen: it walks the tree, and for each node, it knows which component to use.
NB: This registry is the limit of SDUI. What can vary remotely is the composition and the content. What cannot vary without a redeployment is the set of types the client knows.
The Link Between Interface and Data
Some nodes don't just display content. They connect to two other elements of the schema.
The first link connects a node to a data value. The node indicates the data key it represents (this is valuePath in the schema), and the client reads that value to display it. If the server changes the value, the display follows on the next render, without requiring any screen-specific logic on the client.
The second link connects a node to an action. The node indicates the name of the action to trigger when the user interacts with it. The client does not know the URL or the HTTP method of that action; it sends the expected data to the named action, and the actions property provides the rest.
This separation is the core of the schema: the client knows names, the server decides the addresses and the rules.
NB: These two links are also the most fragile points of the schema. If a key in
datais renamed on the server without the client knowing, the node displays a wrong value with no visible error (which is why it's worth versioning the schema ๐).
The Lifecycle of an Interaction
A full interaction takes two requests. The client loads the screen, the user changes a value, and the server returns the updated screen.

The application state only changes after the server responds. This approach, called "pessimistic", guarantees that the screen never shows a state the server has not validated.
An Echo of HTMX
This principle is reminiscent of HTMX. In both cases, the server decides what is displayed and what is possible, and the client executes.
The difference lies in what travels over the wire. HTMX returns HTML ready to insert, which only makes sense in a browser. SDUI returns a description independent of the rendering, which can be displayed by a browser or a mobile application, each with its own components.
In short, Server Driven UI rests on three elements:
- A schema that separates state (
data), presentation (ui), andactions; - A finite component registry, identified by their
type; - Explicit links:
valuePathconnects a component to a piece of data, and an action name connects a component to an entry inactions, whatever event (onClick,onChange, etc...) triggers it.
The client decides neither the structure of the screen, nor the URL of an action, nor the business rule that enables or disables a control. It only decides how to display what it knows.
Act III: Server-Side Use Case
The server decides what is displayed, and what it accepts.
As mentioned earlier, the server provides the schema as a document that clients can interpret. The server is responsible for building the schema from state and business rules, for validating what it receives in return, and for exposing all of this through simple routes.
The use case below demonstrates a simple handling of a user preferences screen with Hono. It's up to you to adapt the approach to the different technical and functional contexts of your projects ๐
Typing the Schema
The document received by the client has a precise shape. On the server side, this shape is described by a types file, which serves as the reference for building the screen.
export type ScreenData = Record<string, unknown>;
interface UIStack {
type: 'stack';
children: UIComponents[];
}
interface UISection {
type: 'section';
props: { title: string };
children: UIComponents[];
}
interface UIText {
type: 'text';
props: { value: string; style?: 'body' | 'muted' };
}
interface UISwitch {
type: 'switch';
props: {
label: string;
description?: string;
valuePath: string;
onChange?: string;
disabled?: boolean;
};
}
export type UIComponents = UIStack | UISection | UIText | UISwitch;
export interface ScreenAction {
method: 'PATCH' | 'PUT' | 'POST';
href: string;
}
export interface UIScreen {
version: number;
screen: string;
data: ScreenData;
ui: UIComponents;
actions: Record<string, ScreenAction>;
}
Based on the example above, the schema is represented by UIScreen, whose properties (version, screen, data, ui, actions) were presented earlier. UIComponents is the union of the four node types, and its type field is the discriminant that lets the client know which component to display.
NB: The schema exists in two copies, one on the server side and one on the client side. Even though the two applications live in two separate repositories, the goal is to make them converge toward a single schema.
Finally, ScreenData is nothing more than a simple Record<string, unknown>, which means the server does not guarantee the type of the values to the client; the client will therefore have to check them before using them. The Zod dependency addresses this need: a single schema describes the expected shape, infers the typing, and validates the data received.
Building the UI
Composing the screen happens in a mapping function. It takes the user's state and returns a UIScreen, conforming to the schema.
export const buildPreferencesScreen = (user: User): UIScreen => ({
version: 1,
screen: 'preferences',
data: { ...user.preferences },
ui: {
type: 'stack',
children: [
{
type: 'section',
props: { title: 'Notification Preferences' },
children: [
{
type: 'switch',
props: {
label: 'Enable Email Notifications',
valuePath: 'emailNotifications',
onChange: UPDATE_PREFERENCE_ACTION
}
}
// ...
]
}
]
},
actions: {
[UPDATE_PREFERENCE_ACTION]: {
method: 'PATCH',
href: `/api/preferences/${user.id}`
}
}
});
The structure (ui) is imposed by the server, not by the client. The action's address (href) is also built here: if the URL changes, only the server is modified.
NB: This function is meant to be "pure". It receives data and returns a schema, without knowing anything about HTTP. This is what makes it possible to test it in isolation, and to reuse it if the screen is served differently.
Validating Data
A route that writes the received body directly seems sufficient, until a client sends something other than what is expected...
curl -X PATCH http://localhost:3000/api/preferences/a1b2c3d4 \
-H 'content-type: application/json' -d 'null'
curl -X PATCH http://localhost:3000/api/preferences/a1b2c3d4 \
-H 'content-type: application/json' -d '{"whatsApp": true}'
curl -X PATCH http://localhost:3000/api/preferences/a1b2c3d4 \
-H 'content-type: application/json' -d '{"darkMode": "enabled"}'
Here, the first failure comes from a default value that does not apply to null. The other two requests show that the schema can be corrupted without any visible error: the key does not exist in the model, or the value is not a boolean. This is why it's important to put data validation mechanisms in place. Here is a first approach:
const PREFERENCES_KEYS = ['darkMode', 'emailNotifications', 'pushNotifications'];
const isPreferencesBody = (body: unknown): body is Partial<Preferences> => {
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
return false;
}
const entries = Object.entries(body);
const isPreferenceEntry = entries.every(([key, value]) => {
return PREFERENCES_KEYS.includes(key) && typeof value === 'boolean';
});
return entries.length > 0 && isPreferenceEntry;
};
Then, the service applies this rule before any write, and returns a failure reason, not an HTTP code:
export const setPreferences = (userId: string, body: unknown): SetPreferencesResult => {
if (!isPreferencesBody(body)) return { ok: false, reason: 'bad-request' };
const user = savePreferences(userId, body);
if (!user) return { ok: false, reason: 'user-not-found' };
return { ok: true, screen: buildPreferencesScreen(user) };
};
NB: The server validates because it is the one deciding what goes into the schema. A rule applied only on the client would be bypassed by the first direct HTTP call.
Routing
The routes stay lightweight. They read the request, call the dedicated service, and translate its result into an HTTP response.
import { Hono } from 'hono';
import { setPreferences } from './service';
export const prefRoutes = new Hono();
prefRoutes.patch('/:userId', async ctx => {
const userId = ctx.req.param('userId');
const body = await ctx.req.json().catch(() => null);
const result = setPreferences(userId, body);
if (!result.ok) {
const status = result.reason === 'user-not-found' ? 404 : 400;
return ctx.json({ error: result.reason }, status);
}
return ctx.json(result.screen);
});
In the example above, the catch turns an unreadable body into null, which the service handles correctly. The response to a change is not a simple "200 - OK": it is the complete screen. The client has nothing to recompute! Note that the GET follows the same pattern.
One Schema, Several Servers
The schema is a document. It depends neither on the framework nor on the language that produces it: the client doesn't know which server issued it. This property makes it possible to rewrite the server later without touching the client, as long as both implementations produce the same document.
Act IV: Client-Side Use Case
The client decides nothing; it reads, displays, and transmits.
The server has produced the schema, and the client now has to turn it into a visual interface. Its role is to check that it can read the document, to map each node to a component, to transmit actions by their key or name, and to update the screen with the server's response.
Again, the use case below demonstrates a simple handling of a user preferences screen, this time with Preact. It's up to you to adapt the approach to your framework and/or favorite Web library ๐
Reading the Schema
Before displaying anything, the client checks the schema version. A schema it doesn't know how to read is refused, and the user sees a message rather than a broken interface.
const SUPPORTED_VERSION = 1;
const canRender = (screen: UIScreen): boolean => screen.version === SUPPORTED_VERSION;
As mentioned earlier, the server does not guarantee the type of the data: it is not verified once and for all. The schema declares data as a set of unknown values, and the client checks each value at the moment it reads it.
const readBoolean = (data: ScreenData, key: string): boolean => {
const value = data[key];
return typeof value === 'boolean' ? value : false;
};
NB: A missing value, or one of the wrong type, becomes
false. The client doesn't crash, but it displays a default state that may not match the server's reality.
The root component then applies this check before handing the screen to the rendering engine. The screen is loaded, then the version is checked, and finally the interface is displayed. This way, a refused schema produces no component at all.
export const App = () => {
const [screen, setScreen] = useState<UIScreen | null>(null);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
fetchScreen().then(setScreen).catch(setError);
}, []);
if (!screen) return <p>Loading...</p>;
if (!canRender(screen)) return <p>Update Required!</p>;
if (error) return <p>{error.message}</p>;
return renderNode(screen.ui, { data: screen.data, onAction: handleAction });
};
The Component Rendering Engine
The engine walks the ui tree. For each node, it looks at the type field and picks the matching component from the component registry it has.
export const renderNode = (node: UIComponents, ctx: RenderContext) => {
switch (node.type) {
case "stack":
return (
<div class="stack">
{node.children.map((child) => renderNode(child, ctx))}
</div>
);
case "section":
return (
<section>
<h2>{node.props.title}</h2>
{node.children.map((child) => renderNode(child, ctx))}
</section>
);
case "text":
return <p class={node.props.style === "muted" ? "text-muted" : undefined}>{node.props.value}</p>;
case "switch": {
const { label, description, valuePath, onChange, disabled } = node.props;
return (
<ToggleSwitch
label={label}
description={description}
checked={readBoolean(ctx.data, valuePath)}
disabled={disabled}
onChange={(value) => {
if (onChange) ctx.onAction(onChange, { [valuePath]: value });
}}
/>
);
}
default:
console.log(`Unknown Type: ${node.type}`);
return <p class="text-danger">Unavailable Content</p>;
}
};
The switch is deliberately explicit: each schema type maps to a readable line. Finally, default is a safeguard against a type added on the server side. It provides a fallback without rendering the unsupported component.
NB: The
defaultbranch is not enough to make a new feature available. If the server sends an unknown type, the user sees a message and/or a default state. The missing component has to be added to the client, which requires a redeployment of the application.
Design System Friendly
The switch nodes don't draw their own appearance. They delegate to a Design System component, ToggleSwitch, which receives its data through props and keeps no state of its own.
interface ToggleSwitchProps {
label: string;
description?: string;
checked: boolean;
disabled?: boolean;
onChange: (next: boolean) => void;
}
The component knows neither the key (darkMode), nor the action's URL, nor the rule that disables the interaction. It displays what it is given, and reports changes. This separation is what allows several screens to reuse the same switch, with the same look everywhere. Ideally, each node should reference all or part of the application's Design System (whether it's an atom, a molecule, or an organism).
Triggering an Action
When the user changes a switch, the client doesn't build a URL. It finds the action by its key (or name) in actions, and uses the method and address it contains.
const sendAction = async (action: ScreenAction, payload: Partial<Record<string, boolean>>): Promise<UIScreen> => {
const response = await fetch(action.href, {
method: action.method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const json = await response.json();
if (!response.ok) {
throw new Error(json.error);
}
return json as UIScreen;
};
Finally, the component that uses the rendering engine applies the same rule: if the action's key (or name) doesn't exist in the actions property, nothing is sent to the server.
const handleAction = async (name: string, payload: Partial<Record<string, boolean>>) => {
const action = screen.actions[name];
if (!action) return;
try {
setScreen(await sendAction(action, payload));
setError(null);
} catch (err) {
setError(err);
}
};
Updating the UI
When the server's response arrives, the client replaces the screen to keep the interface visually consistent. It must not compute the new state itself. In case of an error, the previous screen stays displayed, with a default message: the user always sees a version the server has validated.
This approach is called "pessimistic". It is simple to reason about, but it makes the interface a bit slower during interaction, since the element waits for the response before being updated.
Take the example of the "darkMode" preference: dark mode is just like any other piece of data. The client reads it, but it is up to the client to decide how to apply it, for example with a class on the page. The server provides the value, and the client chooses the visual effect.
Going Further...
There is a variant, called "optimistic" (React 19's useOptimistic hook will probably ring a bell ๐), which displays the change immediately, then reconciles it with the response, or rolls it back if it fails. It improves the sense of responsiveness, but it requires the client to predict the outcome. What is predictable, such as a boolean value, lends itself well to this approach. On the other hand, what depends on server rules, such as the composition of the screen, does not. But a "hybrid" mode is possible...
Act V: Best Practices
After theory and practice, here is what you should take away about how Server Driven UI works.
Schema Versioning
Adding a field or a node type remains possible: an older client ignores what it doesn't know. Removing or renaming a field, however, breaks the clients that still use it. So we add without removing, and we only increment version for a real breaking change.
NB: Versioning is decided from the very first published schema, because a field that has been shipped becomes a promise.
Caching
Be careful, however, with data caching in SDUI. A cached schema keeps its old structure: a switch left on may keep displaying until the cache expires. The cache should therefore stay short, or be invalidated on every change. It is also advisable to mark responses as private:
ctx.header('Cache-Control', 'private, no-store');
Trusting the Server
A malformed schema can break the rendering, or be misinterpreted without any visible error. Addresses and the links between the interface and the data deserve the same caution. Restricting actions to relative paths, or to a known origin, limits the damage if external data ends up in the construction of the schema.
NB: The server remains the source of truth. The client limits the damage, it does not replace validation.
Accessibility
A component may be correct, the screen may display, and its label may still not be associated with the control. You therefore need to test components independently of the screen that uses them (much like unit tests ๐งโ๐ป). A switch must announce its name and its state, and the fallback message must be read out by a screen reader.
Pessimism Vs. Optimism
The pessimistic approach is simple to reason about, but slow to respond to a click. The optimistic approach improves responsiveness, but requires predicting the outcome. A boolean value is easy to predict; a composition rule cannot be predicted without duplicating the server logic.
Reminder on Limits...
SDUI doesn't suit every context: a single platform, stable screens, high interactivity, or a small team make the cost hard to justify. Before getting started, four questions help you decide:
- Do the same screens need to exist on several platforms?
- Do mobile redeployments really slow down content changes?
- Can the team maintain a versioned schema and a rendering engine per platform?
- Do the screens change often enough to justify this flexibility?
If most of the answers are yes, Server Driven UI is justified. Otherwise, a traditional JSON API (or a hypermedia approach like HTMX) remains the right choice.
Conclusion
Remember the label that was waiting for a review before it could be published on a mobile store; it now waits for a change on the server side. The gain is real! But it has a price... A schema to version, a rendering engine to maintain on every platform, and constant vigilance over data validation (not to mention caching).
In terms of design, the server describes what is displayed and what is possible, and the client decides how to display it. This split doesn't suit every project, but when the problem is real, it turns a line of code into a change that needs no redeployment. Server Driven UI does not remove complexity, it moves it!
