Building a Server-Driven UI Server Using TSX

A couple of days back, I introduced the idea of Server-Driven UI. In this post, I’ll show you how we use TSX to create the server part. I have to admit, this feels strange. But if you follow along, I believe I can convince you that it’s actually not.

Keep your mind open, and I’ll show you something special.

Please note. I’ve added a lot of sample code to clarify the concepts in this post. This code cannot be copied as-is and be expected to work. I may release a working repository in the future. Let me know if that would be interesting.

Running TSX on the server

In the first part of this series, I showed an accounts screen implemented as a React view. I noted that the code was not really UI code but instead a way to describe the layout in a way that could be useful on any platform. What makes this possible is not React itself; instead, TSX is the real enabler here (or JSX if you are using JavaScript instead of TypeScript).

So, we write our frontend code in TypeScript, and it’s not uncommon to use Node.js on the server side. Would it be possible to move the TSX code to the server? Let’s find out.

This is the view we are moving.

function AccountsScreen({
  totalDebt, totalSavings, creditCardAccounts, savingsAccounts
}: Props) {
  return (
    <List title="Accounts">

      <WidgetListItem>
        <Widget
          icon={Icon.ArrowDown}
          label="Total Debt"
          value={totalDebt}
        />
        <Widget
          icon={Icon.ArrowUp}
          label="Total Savings"
          value={totalSavings}
        />
      </WidgetListItem>

      {creditCardAccounts && (
        <ListSection title="Credit Card Accounts">
          {creditCardAccounts.map((account) => (
            <DataListItem
              icon={Icon.Card}
              label={account.name}
              value={account.debt}
              url={`/accounts/${account.accountNumber}`}
            />
          ))}
        </ListSection>
      )}

      {savingsAccounts && (
        <ListSection title="Savings Accounts">
          {savingsAccounts.map((account) => (
            <DataListItem
              icon={Icon.Money}
              label={account.name}
              value={account.balance}
              url={`/accounts/${account.accountNumber}`}
            />
          ))}
        </ListSection>
      )}
    </List>
  )
}

After the move, we need to tell the TypeScript compiler for the server package to recognize TSX by adding "jsx": "react-jsx" to tsconfig.json.

This will turn this TSX code…

<List>
  <ListSection title="Savings Accounts">
    <DataListItem>...</DataListItem>
    ...
  </ListSection>
</List>

…into this TypeScript code.

import { jsx as _jsx } from "react/jsx-runtime"

_jsx(List, {
  children: _jsx(ListSection, {
    title: "Savings Accounts",
    children: [
      _jsx(DataListItem, { children: ... }),
      ...
    ]
  })
})

The only problem with this transformation is the import statement ...from "react/jsx-runtime". It uses the React runtime, and that’s not what we are looking for. We don’t need to create actual views, we don’t need life cycle management, and we don’t need hooks. We just want to use TSX to describe our view.

To fix this, we need to create an implementation of the _jsx function and tell the TypeScript compiler to import that instead by adding "jsxImportSource": "<path-to-your-implementation>" to tsconfig.json.

We want our implementation to transform the views into a JSON representation, but you can choose Protobuf or whatever you want. This is our implementation.

type ComponentDefinition = {
  name: string
  props?: any
  children?: ComponentDefinition[]
  key: string
}

function jsx(tag: any, { children, ...props }: any, key: any): ComponentDefinition {
  const tagName = tag.name
  if (tagName === undefined) {
    throw Error(`Invalid tag '${tag}'. Only custom elements are allowed.`)
  }

  if (!Object.keys(props).length) {
    props = undefined
  }

  if (!children) {
    children = undefined
  } else if (!Array.isArray(children)) {
    children = [children]
  }

  return {
    name: tagName,
    props: props,
    children: children?.filter((c: any) => c !== null),
    key
  }
}

const Fragment = () => null

export { Fragment, jsx, jsx as jsxDEV, jsx as jsxs }

The code is not that complex and when we’ve configured this we can actually run our API again and it will return this JSON based on our previous React-example.

{
  "name": "List",
  "children": [
    {
      "name": "ListSection",
      "props": { "title": "Savings Accounts" },
      "children": [
        { "name": "DataListItem", "children": ... },
        ...
      ]
    }
  ]
}

Now, we can define our views using TSX, which will be serialized automatically into JSON. The component definition is recursive, so since we know how to handle one component, we can handle them all. This allows us to add an unlimited number of components without ever changing the API.

But… but… why?

Ok, I know this is a bit strange, and it’s not unreasonable to create regular objects on the server and serialize them just as you usually do when building an API.

But building a UI is different.

In the golden days of yore, we used imperative code to write our UI using Java Swing or something similar. That wasn’t great though, and over time, we’ve moved more and more towards declarative UI. From HTML all the way to React, SwiftUI, and Compose. Now, we declare our UI, and it’s up to the platform to render it in the most efficient way.

This is what we do here.

We declare our UI using TSX, and the rendering happens to serialize the content as JSON, and (as your will see in the next post) deserialize it back to HTML using React, an iOS app using SwiftUI, and so on. This is a great way to code.

Another huge benefit is that we can reuse the components we create for the web. This means that the server and the web are always in sync. We still need to ensure that Android and iOS are in sync as well, but for now, this allows us to move really fast.

Open source alternatives

One of the questions I always get asked when discussing this is:

Is there a framework to do this?

We are always quick to jump to frameworks and libraries to solve our problems, but then we are stuck with CVE checks and emergency patches. Of course, there are frameworks, and I encourage you to check them out for inspiration.

However, this is relatively easy, and our entire solution is just a couple of hundreds of lines of code. Code that we write, that we control, and code that we can proudly take responsibility for. I’ve written more code in the past to trick frameworks into doing something I wanted.

Implementing the server

How you implement your server is up to you. We use AWS Lambda with a standard three-layered clean architecture. We use TSX in the presentation layer to “render” our views, a small adapter layer to ensure it can run in AWS Lambda, and different repository implementations hidden behind interfaces as they fit. Still, it’s just portable TypeScript code that we can run anywhere.

One great thing about this approach is that we can slice it any way we want. If we want to build a monolith, we can do that; if we wish to embrace microservices, we can do that. Right now, we’ve divided our services based on domain boundaries that we deploy as different stacks on AWS, and that works great.

Versioning

One thing to note about the implementation is that while we can control what we return from the server, we cannot control the apps that call us. Not all people update their apps immediately, so we must be prepared.

Our approach is to send a header with the supported version of the component library from our apps. This way, the server can send a version of the view that the app can render.

In the image above, users get a cool trendline if they’ve updated to an app that supports component library version 74. Otherwise, they will receive a banner telling them to upgrade. Sometimes, a simple text representation of the content may be a better solution for older devices.

The developer experience

One thing to consider is the developer experience. If you’ve done any React, SwiftUI, Compose, or Flutter development, you are spoiled for life. You expect hot-reloading, and waiting 30 seconds for the server to reload is not good enough.

So when you choose your server stack, make sure it can reload the content fast. If you use AWS Lambda, consider Serverless Framework’s offline plugin or SST’s Live Lambdas. SAM Accelerate or CDK Watch is not fast enough. Another solution is to use Express.js locally. If you’ve architected your server well, adding an API layer using Express.js locally should be straightforward.

Summary

In this post, I’ve described one approach to creating the server for Server-Driven UI. It still takes some design and architecture to ensure a smooth experience, but if done right, this can be extremely powerful.

In the final post of this series, I will create apps that use this API, and whenever you change something on the server, every app will get that change immediately.

It’s like magic!