Server-driven UI
The background of server-driven UI, how it works in Backlit, and where it fits.
Server-driven UI (SDUI) is an architecture in which the server decides what a screen contains and the client decides how to draw it. The server response is not raw data that the client must interpret. It is a description of the screen: a tree of typed nodes, with the data for each node inside it. The client has one component for each node type and renders the tree.
Background
The web browser is the oldest SDUI client. A server sends HTML, the browser renders it, and the links and forms in the response tell the browser which requests it can make next. The REST architecture calls this constraint hypermedia as the engine of application state (HATEOAS). Single-page applications moved away from it. The server became a JSON API, and the knowledge of each screen moved into client code.
Mobile teams brought the idea back, because a native app cannot change a screen without a release through an app store. Airbnb built the Ghost Platform, where one GraphQL schema of sections, screens, and actions drives the web, iOS, and Android clients. Most of its high-traffic features, such as search, the listing page, and checkout, run on it. Lyft moved its bikes and scooters product to SDUI to increase release speed and to keep business logic out of three client codebases.
These systems return components that have a meaning in the product, and not HTML or layout primitives. The client owns the visual result, so each platform keeps its native look and the design stays consistent.
How it works in Backlit
The Backlit SDK builds a tree of protocol nodes for each view. Each node has a
kind. The example below declares a table. The protocol output shows the node that
the server sends.
import { defineResource, fields, table } from '@backlit/sdk'
const orders = defineResource('orders', [
fields.text('reference', { label: 'Order' }),
fields.text('customer', { label: 'Customer' }),
fields.number('total', {
label: 'Total',
format: 'currency',
currency: 'USD',
}),
])
orders.view('list', () =>
table([
{
reference: 'ORD-1042',
customer: 'A. Customer',
total: 149.5,
},
{
reference: 'ORD-1043',
customer: 'B. Customer',
total: 89,
},
]).setFields(
orders.pick('reference', 'customer', 'total')
)
)
export default orders{
"kind": "success",
"status": 200,
"node": {
"kind": "view",
"resource": "orders",
"name": "list",
"slots": {
"content": [
{
"kind": "table",
"fields": [
{
"kind": "text",
"name": "reference",
"label": "Order"
},
{
"kind": "text",
"name": "customer",
"label": "Customer"
},
{
"kind": "number",
"name": "total",
"format": "currency",
"currency": "USD",
"label": "Total"
}
],
"data": [
{
"reference": "ORD-1042",
"customer": "A. Customer",
"total": 149.5
},
{
"reference": "ORD-1043",
"customer": "B. Customer",
"total": 89
}
]
}
]
}
}
}The web client has a registry that maps each node kind to a React component. It walks the tree, finds the component for each kind, and renders it. A kind that the registry does not know renders a fallback component and does not stop the page.
Interaction follows the same model. The server declares what a user can do, as targets and actions inside the nodes: open a view, submit a form, run an action on a row. The client does the request. The server answers with one of four responses, which are success, redirect, refresh, or error, and the client applies it. The client has no knowledge of your domain. It knows the protocol only.
The protocol contains only data that a client cannot infer. It has no pixel values, no colors, and no layout coordinates. Thus a client for a different platform can render the same response with its own components.
Why SDUI fits internal apps
Internal apps are built from a known set of parts: tables, forms, filters, detail pages, charts, and actions. The value of an internal app is in the data and the operations, and not in a new interface.
An SDUI protocol works when a fixed catalogue of parts can express the screens of an application. Airbnb and Lyft each designed a catalogue for one product. The screens of internal apps are similar enough between companies that one catalogue can serve all of them, and Backlit ships that catalogue, designed for the back office.
The catalogue is where you start, and it does not limit what you can build. The next two sections cover the work that goes beyond it.
Extend Backlit
Backlit is built to be extended from day one. An extension is a package with a server module and a client module, and it can add these things:
- New field types, with their data conversion and their rendering.
- New blocks, widgets, and charts.
- Complete resources, with their views and actions.
The last item means that an extension can ship finished screens, and not only parts for your screens. A queue monitor or an audit log can be an extension that you install, and the pages appear in your app. You can write extensions for your own apps, share them between the apps of your company, or publish them.
The Extensions page shows an extension that adds an operations dashboard for BullMQ queues, with the code that registers it.
Write a custom page
Some screens are specific to one company and one use case, and no catalogue fits
them. For these, you write a custom page. You add a JSX file to the pages
directory of the frontend app, and the file becomes a route inside the Backlit
shell. The page is normal React, so the interface has no constraints.
A custom page still uses the platform. The components of the design system are available, so the page looks the same as the other pages of the app. The React Query integration is available, so the page gets the same data fetching and caching as a Backlit view.
import { useQuery } from '@tanstack/react-query'
import { Card, CardContent, CardHeader, CardTitle } from '@backlit/shadcn-design-system'
import { ForecastCanvas } from '../components/forecast_canvas.tsx'
export default function RevenueForecast() {
const { data } = useQuery({
queryKey: ['revenue-forecast'],
queryFn: () => fetch('/api/forecast').then((response) => response.json()),
})
return (
<Card>
<CardHeader>
<CardTitle>Revenue forecast</CardTitle>
</CardHeader>
<CardContent>
{/* Your own component. Backlit puts no limit on what it renders. */}
{data && <ForecastCanvas series={data.series} />}
</CardContent>
</Card>
)
}