Should 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:

  • version indicates 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;
  • screen identifies the screen. It lets the client know the display context, for example for tracking or cache management;
  • data holds the state, that is, the current values. It contains no presentation information;
  • ui describes the component tree, their structure and their content, without carrying any state value;
  • actions declares the possible actions, with their HTTP method and their address. The client does not know them in advance.

NB: Separating data and ui is 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 data is 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.

sequenceDiagram participant U as User participant C as Client participant S as Server C->>S: Request Screen S-->>C: Screen (data, ui, actions) C->>C: Render UI Tree U->>C: Toggle Dark Mode C->>S: Send Action {"darkMode": false} S-->>C: Updated Screen C->>C: Rerender Updated UI Tree

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), and actions;
  • A finite component registry, identified by their type;
  • Explicit links: valuePath connects a component to a piece of data, and an action name connects a component to an entry in actions, 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 default branch 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 in...