JUN 26, 20268 min read

Entity APIs as the Spine of Server-Driven Screens

Overview

Most mobile screens are not unique. They are an ordered list of typed blocks -- a header, a list, a call-to-action -- assembled from data the backend already owns. If you accept that, a screen stops being bespoke layout code and becomes a value: a map of sections plus an order.

Context

This post describes a pattern with two pieces. An entity API is a uniform backend read surface: ask it for entity X for the current user and it returns raw JSON, nothing about layout. A server-driven screen is the consequence: instead of the app hard-coding what each screen looks like, the backend (or a thin client builder) hands the app a description of the screen as data, and the app renders it.

The glue between them is a ScreenModel: an ordered map of typed sections. The client reads an entity, folds it into a ScreenModel, and a single reusable host renders every tab through one section registry. In this setup the backend service is a Python/Flask API, and the client app is React Native with Expo, but the idea is stack-agnostic.

The screen, visualized

Here is a home screen as a reader would see it: a progress header, a list of subjects, an upgrade call-to-action, and a streak indicator. Each visible block maps one-to-one to a named, typed section.

A "multisection" screen is exactly this: a named map of typed sections rendered in order, optionally with an aside pane for two-pane desktop layouts.

 ┌──────────────────────────────────────────┐
 │  progress_header     streak: 4 days  *    │
 ├──────────────────────────────────────────┤
 │  Your subjects                            │
 │   ┌────────┐  ┌────────┐  ┌────────┐      │
 │   │  Math  │  │ Science│  │ History│      │
 │   └────────┘  └────────┘  └────────┘      │
 ├──────────────────────────────────────────┤
 │  subscriptions_cta   [  Upgrade to Pro ]  │
 ├──────────────────────────────────────────┤
 │  streak              4-day streak, keep!  │
 └──────────────────────────────────────────┘

Before and after

The contrast is the whole argument. Before, each screen fetched its own bespoke endpoints and hand-built its own UI, so every tab reimplemented its own loading, empty, and error chrome. After, every screen reads through one uniform entity surface and renders through one ScreenModel host, so there is a single render path and chrome is handled once.

 BEFORE                              AFTER
 ┌─────────────┐                     ┌─────────────┐
 │ Home tab    │--GET /home-->API    │ Home tab    │
 │  own fetch  │                     │             │
 │  own chrome │                     │  one entity │
 ├─────────────┤                     │  read +     │
 │ Subject tab │--GET /subj-->API    │  one host   │--GET /entity-->API
 │  own fetch  │                     │             │
 │  own chrome │                     │  chrome     │
 ├─────────────┤                     │  handled    │
 │ Offers tab  │--GET /offer->API    │  once       │
 │  own fetch  │                     │             │
 │  own chrome │                     └─────────────┘
 └─────────────┘

Scenario: reorder the home screen for an experiment

Suppose product wants to test pushing the upgrade CTA above the subjects list. Before, layout was baked into the app: changing the order means a code change, a new release, and a wait for adoption, with no way to target a subset of users. After, the order lives in the ScreenModel JSON: reorder the array, swap a section variant, or flag-gate a section server-side. The change is live immediately and can be A/B tested.

 BEFORE                          AFTER
 layout in app code              order in ScreenModel JSON
   change -> build -> release      edit array -> served live
   no targeting                    per-user / A-B, no release

One read surface: the entity API

Before sections or screens exist, the backend has to answer one question uniformly: give me entity X for this user. The entity API is a single declarative read surface, not a pile of hand-written handlers. Each entity is registered once and served under a uniform route.

EntityRouteBacking
subjectsGET /entity/subjectsmodel-backed
lesson treeGET /entity/tree?subject_id=computed
content itemGET /entity/content?item_id=computed
feature flagsGET /entity/feature-flagsmodel-backed
preferencesGET /entity/preferencesmodel-backed
offersGET /entity/offersmodel-backed

Some entities are model-backed (read a record, serialize it) and some are computed (run a function, optionally adding a server-side pay-gate). Either way the client sees the same shape, so it never learns six different fetch idioms.

From entity to ScreenModel

A raw entity is not a screen -- it is the ingredients. A builder maps each entity into the section map and emits a ScreenModel: { order?, sections: Record<key, SectionDef>, aside? }. Each SectionDef is { type: string } & Record<string, unknown>, so a section is just a type tag plus the props that type consumes.

{
  "order": ["progress_header", "subjects_list", "subscriptions_cta"],
  "sections": {
    "progress_header": { "type": "progress_header", "streakDays": 4 },
    "subjects_list": {
      "type": "subjects_list",
      "sectionTitle": "Your subjects",
      "items": [{ "id": "math", "title": "Math", "cover_url": "math.png" }]
    },
    "subscriptions_cta": { "type": "subscriptions_cta", "event": "open_paywall" }
  }
}

The builder is the only place that knows how a given entity becomes sections. Move it server-side later and nothing else changes: the host, the registry, and the JSON shapes already match.

One host renders every screen

If every screen is a ScreenModel, then only one component needs to know how to render screens. A data adapter seam exposes fetchScreen(tab) and fetchEntity(name, params); the host receives a resolved ScreenModel and never knows where the JSON came from. A rejected fetch resolves to null, which triggers a fallback so a tab never renders blank.

The host resolves order = model.order ?? Object.keys(model.sections), renders each section through a section registry inside a frame that owns the loading, error, empty, and unauthorized chrome centrally. The registry dispatches section.type and graceful-skips unknown types instead of throwing. No tab reimplements fetch states, and an unrecognized section degrades to nothing rather than crashing the screen.

 FRONTEND                                  BACKEND
 ┌──────────────────────────┐             ┌──────────────────────┐
 │ fetchEntity(name) ───────┼── GET ─────>│ /entity/<name>       │
 │                          │             │  spec / computed     │
 │                          │             │  (+ pay-gate)        │
 │ assemble ScreenModel <───┼─ entity JSON┤                      │
 │  -> {order, sections}    │             └──────────────────────┘
 │ host: order ?? keys      │
 │  for each -> registry    │
 │  registry[type] -> block │
 │  unknown -> skip         │
 │  reject -> fallback      │
 └──────────────────────────┘

A/B testing by reshaping JSON

Because a screen is just {order, sections}, an experiment is a data edit, not a build. Reorder the array to push the CTA above the fold. Swap one section for a variant section of a different type. Toggle a section in or out by reading the feature-flags entity and dropping or keeping its key. All three are server-side JSON changes against already-registered section types, so no client release is required to run, ship, or roll back a variant.

Design principles at work

  • Single responsibility (SRP). Each entity returns one thing; each section renders one block; the host only orchestrates order and chrome. No component carries two jobs.
  • Don't repeat yourself (DRY). One entity read engine and one ScreenModel host replace bespoke screens and the loading/empty/error chrome each used to repeat.
  • Inversion of control / dependency injection (IoC/DI). The data adapter and the screen assembler are injected; the host does not know where JSON comes from, so client builders and server proxies are interchangeable.
  • Publish/subscribe, event-driven. Sections emit events like open_paywall; the host or a runner subscribes and routes them, so a section never reaches into navigation or payments directly.
  • Model-view-controller (MVC). The entity and ScreenModel JSON are the model, the section components are the view, and the host plus adapter are the controller.

Why product and engineering both win

  • A new screen is mostly data. With a uniform entity read surface and one host, building a screen is "fetch an entity, name some sections, pick an order."
  • Chrome is centralized. Loading, error, empty, and unauthorized states live in the host once. No tab reimplements them, and none can render blank.
  • Failure degrades gracefully. A rejected fetch falls back; an unknown section type is skipped, not fatal.
  • Experiments need no release. Product reorders, swaps, or flag-gates sections in JSON; the client renders any valid ScreenModel through the same path.
  • The server migration is a seam, not a rewrite. Host, registry, and JSON shapes already match, so moving the builder behind fetchScreen(...) is a single substitution.