Skip to content
HotLayout Docs

Quickstart for React

This page is for a web developer. It takes you from an empty React app — Vite or Next.js — to a screen that a manager published in HotLayout and that your site draws. Plan for half an hour. Prefer to watch first? The same path on video (3 min).

The code blocks marked with a file name are verbatim regions of the package's examples/ folder, which is compiled together with the package: github.com/hotlayout/hotlayout-react.

Imports. Some regions start below the import lines of their file. Everything the package gives comes from its one entry — values as import { HotlayoutScreen, loadDocument, loadTheme, checkDocumentVersion, runStandardAction, openInNewTab } from '@hotlayout/react', types as import type { UiDocument, ThemeContract, DataScope, HotlayoutAction, ActionSource } from '@hotlayout/react' — and useState and useEffect come from react. Names such as router, redeem and analytics stand for your own code. The file named above each block has the complete list.

Related pages: index · delivery · data dictionary · theme · localization · compatibility · widgets · quickstart for Flutter

What you are integrating#

Piece What it is
Document One JSON file: a set of screens built from widgets. Published by the builder as published.json.
Theme One JSON file: the token names of the project (primary, md, titleM, and names of your own) and the values behind them.
Data Plain objects your app supplies: values for the paths the document is bound to (user.points) and lists for its collections (items).
Renderer The @hotlayout/react package: one component, <HotlayoutScreen>, that turns the three into DOM.

Package 0.18 reads documents of schema 9.19.x and themes of theme contract 4.0.x, and has icon set 1.0.0 built in. Any 9.x document and any 4.x theme is drawn; a document of another major is refused — see step 9.

The steps:

  1. Install
  2. Draw your first screen
  3. Load the published document and theme
  4. Supply data
  5. Handle actions
  6. Accept what the user wrote
  7. Analytics and screen views
  8. Next.js and server rendering
  9. Versions: what a user sees

1. Install#

Start from an app of your own, or create an empty one.

Vite + React

npm create vite@latest my-app -- --template react-ts
cd my-app
npm install @hotlayout/react

Next.js (App Router)

npx create-next-app@latest my-app --typescript --app --yes
cd my-app
npm install @hotlayout/react

--yes accepts the defaults for the questions the two flags leave open, so the command asks nothing.

Requirements: React 18.2 or newer, or React 19. The package is ESM with types and has no dependencies besides react and react-dom. There is one entry, @hotlayout/react; anything it does not export is internal.

2. Draw your first screen#

You need two JSON files: a document and a theme. For a screen that is already published, save what its two addresses return — see What to ask for in step 3. To try the renderer before you have a project, take document.json and theme.json from the example app of the Flutter package — example/assets/hotlayout. The same JSON is drawn on both platforms.

Put them into src/hotlayout/ and render. In an app made from the Vite template this is src/App.tsx:

import { HotlayoutScreen } from '@hotlayout/react'
import type { ThemeContract, UiDocument } from '@hotlayout/react'

import documentJson from './hotlayout/document.json'
import themeJson from './hotlayout/theme.json'

// The renderer does not validate the files against the schema — the builder did — and never trusts them either.
const layout = documentJson as unknown as UiDocument
const theme = themeJson as unknown as ThemeContract

export default function App() {
  return <HotlayoutScreen document={layout} theme={theme} locale={navigator.language} style={{ minBlockSize: '100dvh' }} />
}

Run the app: you should see the screen. Bound values show the fallback texts written in the document, because you have not passed data yet.

Four things to know at once:

  • Remove the styles of the app template. The Vite template imports src/index.css in src/main.tsx; it gives #root a fixed width, side borders and centered text, so the screen is not as wide as the page. Replace the content of that file with body { margin: 0 }.
  • Give the root a height. The root is a flex column that takes the size you give it (style, className). The screen background reaches its bottom, and modal sheets sit on its bottom edge.
  • Name the variable layout, not document. Inside a component document would shadow the browser's own document.
  • No theme is not an error. Without theme the built-in starter theme is drawn.

The same component with every kind of input — examples/quick-start.tsx:

import { HotlayoutScreen } from '@hotlayout/react'
import type { ThemeContract, UiDocument } from '@hotlayout/react'

export function Rewards({ layout, theme }: { layout: UiDocument; theme: ThemeContract }) {
  return (
    <HotlayoutScreen
      document={layout} // the layout document, parsed JSON
      theme={theme} // the project theme, parsed JSON; omit it to use the built-in one
      data={{ 'user.firstName': 'Ada', 'user.points': 1250 }}
      collections={{ items: [{ name: 'Coffee', price: 300 }] }}
      locale="en"
      style={{ minBlockSize: '100dvh' }}
      onAction={(action, source) => {
        console.log('the user asked for', action.type, action.params, 'on', source.nodeId)
      }}
    />
  )
}

3. Load the published document and theme#

A bundled file changes only with a deploy. The point of the product is that a manager presses Publish and the screen changes without one — so the app re-reads the published files.

What to ask for#

A developer often has no access to the admin. If someone else built the screen, ask them for these four things — the rest of this page needs nothing else:

What Where it is in the admin
The address of the published document — or three ids: organization, project, document The list of documents, Copy address for the app on the row of a published document. The document id stands under the document's name in the same list.
The address of the published theme Project settings → Delivery → Address of the published theme. With the organization and project ids you can write it yourself.
The data dictionary, data-contract.json Project settings → Data dictionary → Open the dictionary → Download data-contract.json. It lists the values and lists your app supplies.
A copy of the document and of the theme to bundle with the app There is nothing to export: open the two addresses and save what they return. They are the same files.

To see which values one particular document reads, run the short script in What a document reads over its file.

Where the files are#

Every publication is a static JSON file in our storage, readable without a token. The host and the bucket are the same for every project; only the three ids change:

https://firebasestorage.googleapis.com/v0/b/ui-builder-dd4af.firebasestorage.app/o/orgs%2F<orgId>%2Fprojects%2F<projectId>%2Fdocuments%2F<documentId>%2Fpublished.json?alt=media
https://firebasestorage.googleapis.com/v0/b/ui-builder-dd4af.firebasestorage.app/o/orgs%2F<orgId>%2Fprojects%2F<projectId>%2Ftheme%2Fpublished.json?alt=media

Replace <orgId>, <projectId> and <documentId> and keep everything else as it is: %2F is an encoded / (the file path is one segment of the address), and without ?alt=media the address returns a description of the file instead of the file. The ready addresses are also in the admin — Copy address for the app in the list of documents and Project settings → Delivery. See Delivery options.

A browser cannot read our storage from your domain#

This is the first thing a web integration runs into. The storage answers cross-origin requests only for origins it was told to allow, and your site is not one of them. curl gets 200 for the address; fetch in a page on https://app.example.com gets TypeError: Failed to fetch. Mobile apps and servers are not browsers and are not affected.

How your web app gets the document When to use it
From your own backend, which received it by webhook The recommended way. The request is same-origin (or to an API whose CORS you control), and your users' browsers never talk to our storage.
On your server — a Next.js Server Component, a route handler, any server code that fetches the direct address Works with the direct address as it is: a server is not subject to CORS. See step 8.
From the browser, straight from our storage Only after your origin has been added to the storage's CORS settings. Write to contact@hotlayout.com.

The rest of this step works with any address the browser is allowed to read.

Re-read on return to the tab#

loadDocument fetches a published file; checkDocumentVersion says whether this build can draw it. A load can fail — no network, a wrong address, an answer that is not a document — so the hook below keeps four states apart instead of showing "Loading…" forever. examples/docs/live-document.tsx:

import { useEffect, useState } from 'react'
import { HotlayoutScreen, checkDocumentVersion, loadDocument } from '@hotlayout/react'
import type { ThemeContract, UiDocument } from '@hotlayout/react'

export interface LiveLayout {
  /** What to draw: the last good read, else the copy bundled with the app, else nothing. */
  readonly layout: UiDocument | undefined
  /**
   * `loading` — the first read has not ended; `fresh` — `layout` is what is published now;
   * `stale` — the last read failed and `layout` is an earlier or bundled copy; `failed` — it failed and there is nothing to draw.
   */
  readonly status: 'loading' | 'fresh' | 'stale' | 'failed'
  readonly error: unknown
  readonly retry: () => void
}

/** The published layout, re-read whenever the user comes back to the tab. `bundled` is an optional copy shipped with the app. */
export function useLayout(url: string, bundled?: UiDocument): LiveLayout {
  const [layout, setLayout] = useState(bundled)
  const [read, setRead] = useState<{ ok: true } | { ok: false; error: unknown }>()
  const [attempt, setAttempt] = useState(0)

  useEffect(() => {
    const controller = new AbortController()
    const refresh = () => {
      loadDocument(url, { bypassCache: true, signal: controller.signal })
        .then((next) => {
          // A layout this build cannot read does not replace the one on screen.
          const verdict = checkDocumentVersion(next.schemaVersion)
          if (verdict.kind === 'incompatible') throw new Error(`layout ${next.schemaVersion}: ${verdict.reason}`)
          setLayout(next)
          setRead({ ok: true })
        })
        .catch((error: unknown) => {
          if (controller.signal.aborted) return
          // Offline, a 404, an answer that is not JSON, another major: keep what is on screen — and say so.
          console.warn('layout not refreshed', error)
          setRead({ ok: false, error })
        })
    }
    const onVisible = () => {
      if (document.visibilityState === 'visible') refresh()
    }
    refresh()
    document.addEventListener('visibilitychange', onVisible)
    return () => {
      controller.abort()
      document.removeEventListener('visibilitychange', onVisible)
    }
  }, [url, attempt])

  const status = read === undefined ? 'loading' : read.ok ? 'fresh' : layout === undefined ? 'failed' : 'stale'
  const retry = () => {
    setRead(undefined)
    setAttempt((count) => count + 1)
  }
  return { layout, status, error: read?.ok === false ? read.error : undefined, retry }
}

export function LiveRewards({ url, theme, bundled }: { url: string; theme: ThemeContract; bundled?: UiDocument }) {
  const { layout, status, retry } = useLayout(url, bundled)
  if (layout !== undefined) return <HotlayoutScreen document={layout} theme={theme} style={{ minBlockSize: '100dvh' }} />
  if (status === 'failed') {
    return (
      <div role="alert">
        <p>We could not load this screen.</p>
        <button type="button" onClick={retry}>
          Try again
        </button>
      </div>
    )
  }
  return <p role="status">Loading…</p>
}
status layout What the user sees
loading the bundled copy, or nothing the bundled copy at once; without one, your loading placeholder
fresh what is published now the screen
stale an earlier read, or the bundled copy the screen; the failure goes to your log, not to the user
failed nothing your message and a way to try again

Pass bundled — a copy of the document saved into your app — when the screen must be there on the first frame and without a network. Every return to the tab is a new attempt, so a stale screen heals itself when the network is back.

With your own backend the address is simply one of yours:

export function RewardsPage({ theme }: { theme: ThemeContract }) {
  // Same origin as the page: your backend serves the copy the webhook delivered. No CORS is involved.
  return <LiveRewards url="/api/ui/documents/reward-catalog" theme={theme} />
}

What the loaders do and do not do:

  • loadDocument(url, options?) and loadTheme(url, options?) resolve to the parsed JSON. A failure is a HotlayoutLoadError with url and status: the request did not happen (no status), the server answered with an error code, or it answered 200 with something that is not a JSON object.
  • The shape is not checked. Any JSON object passes, a theme loaded as a document included; HotlayoutScreen is what refuses a document it cannot draw.
  • Options: signal cancels the request; bypassCache: true adds a unique query parameter so that no cache in between answers; fetch replaces the global fetch.
  • No custom request headers are sent, on purpose: a header such as Cache-Control turns the request into a preflighted one. This is why bypassCache changes the address, not the headers.
  • A new version of the same document keeps the user where they are. The screen stack is tied to documentId, not to the object.
  • They are conveniences. Any way of getting the JSON works: your own API client, a server component, a bundled file as the offline fallback.

The theme is loaded the same way. examples/theme-and-fonts.tsx shows the three ways to give a theme:

// 1. No theme: the built-in starter theme (light; `themeId="dark"` for the dark one).
export const builtIn = <HotlayoutScreen document={layout} />

// 2. Your project's theme — the parsed `theme/published.json`.
export const own = <HotlayoutScreen document={layout} theme={projectTheme} themeId={prefersDark ? 'dark' : undefined} />

// 3. The theme by URL: a designer republishes it, the app picks it up without a release.
export function Themed({ themeUrl }: { themeUrl: string }) {
  const [theme, setTheme] = useState<ThemeContract>()
  useEffect(() => {
    const controller = new AbortController()
    loadTheme(themeUrl, { signal: controller.signal })
      .then(setTheme)
      .catch(() => undefined) // no theme is not an error: the built-in one is drawn
    return () => {
      controller.abort()
    }
  }, [themeUrl])
  return <HotlayoutScreen document={layout} theme={theme} onReport={(report) => { console.warn('hotlayout', report) }} />
}

A theme that cannot be read never blanks the screen: the built-in theme is drawn and onReport gets theme-fallback. The fallback order is on the theme page.

4. Supply data#

data is a flat map from a path of the data dictionary to a value. collections holds the lists, keyed by collection name; a record uses field names (name), not full paths (item.name).

In the data dictionary In your props
paths the keys of data (user.firstName)
collections[].path the keys of collections (items)
collections[].item the prefix of the record fields inside a list (item.name → the name field of a record)

Which paths and lists one document reads is in its JSON: the script in What a document reads prints them.

examples/actions.tsx:

export function Catalog({ layout, shared, items, onRedeem }: { layout: UiDocument; shared: DataScope; items: readonly DataScope[]; onRedeem: (itemId: string) => void }) {
  return (
    <HotlayoutScreen
      document={layout}
      data={shared} // what every screen reads: { 'user.points': 1250, … }
      collections={{ items }} // the list: [{ id: '42', name: 'Coffee set' }, …]
      onAction={(action, source) => {
        // `source.record` is the record the action is about, as a flat map of paths.
        // Pressed in a list row: that row. Pressed on a screen opened from a row: that row too.
        const itemId = source.record?.['item.id']
        if (action.type === 'submit' && typeof itemId === 'string') onRedeem(itemId)
      }}
    />
  )
}

The rules the renderer applies:

  • A missing value is not an error. The binding shows the fallback written in the document. The screen is whole with or without data, so you can render first and fill the data in when it arrives.
  • Keep types. A number stays a number, a boolean stays a boolean. The string "1850" is not a number: it gets no separators and no plural agreement.
  • Dates are ISO 8601 strings with a time and a zone (2026-10-31T23:59:59Z). Anything else is shown as it came.
  • An empty string is "no value" for a text: the fallback is shown. A string of spaces, 0 and false are values.
  • A list draws at most 50 records.
  • A record may carry fields the dictionary does not list — an id, as in the example above. A screen cannot be bound to them, but they reach you in source.record (item.id) with the rest of the row. The standard dictionary has no item.id; add the identifier to your project's dictionary when a screen has to show it or a condition has to read it.
  • New data: pass a new object and re-render. The screen stack is kept.

One screen, many records#

A list row usually opens a details screen for that record, and the renderer does it by itself: a navigate pressed in a list row opens the screen with that row's record. Every action that reaches you says which record it is about in source.record — a flat map of paths, as in the example above.

The action is pressed… navigate opens the screen… source.record
in a list row with the row's record the same record
outside a list, the target is a page without a record the record its screen was opened with, if any
outside a list, the target is a modal sheet with the record of the screen beneath the same
  • The record is stronger than data: on a screen opened from a row, data['item.name'] is not read while the record has a name.
  • The record is a snapshot taken when the row was pressed.
  • Treat an id from source.record as user input. Check on your backend that this user may act on that record.

5. Handle actions#

Watch: Actions, analytics and data in your code (3 min).

A button, a list row or the back arrow carries an action: { type, params? }. The contract names nine of them and says who performs each; any other type is yours.

type Performed by Parameters
none the renderer — does nothing, never reaches you —
navigate the renderer; to a screen the document does not have — you screenId
navigate.back the renderer; on the first screen — you ("leave the flow") —
navigate.section the renderer; to a screen the document does not have — you screenId
setValue, toggleValue the renderer — a write; never reaches onAction, see step 6 path, a value
openUrl you url — an https:// address
deeplink you url — an app link (myapp://offers/42)
close you — the renderer does not touch the screen stack —
anything else (submit, share, your own) you free params

The renderer opens nothing by itself — no window.open, no link with an address from the document. openUrl, deeplink and close arrive in onAction with a verdict: action.standard when the parameters passed the contract's check, action.invalid when one did not. runStandardAction is an optional helper that performs only action.standard, only with the handlers you pass.

examples/actions.tsx (router and redeem are your own):

const ROUTES: Readonly<Record<string, string>> = { checkout: '/app/checkout', support: '/app/support' }

function handleAction(action: HotlayoutAction, source: ActionSource): void {
  // `openUrl`, `deeplink` and `close` arrive checked, and YOU perform them: the renderer opens nothing by itself.
  // The helper runs only what passed the contract's check; an app link only with a scheme you named.
  const done = runStandardAction(action, {
    openUrl: openInNewTab, // or your own: a router, an in-app browser
    deeplinkSchemes: ['myapp'], // without this list a `deeplink` is never performed
    openDeeplink: (url) => {
      router.push(url)
    },
    close: () => {
      router.push('/app')
    },
  })
  if (done) return
  if (action.invalid !== undefined) {
    // `action.params.url` of an invalid action is a raw string from the document: never open it.
    // A catalog action whose parameter failed the check (a bound address that is not `https:`): never run it.
    console.warn('layout action refused', action.type, action.invalid.param, action.invalid.problem)
    return
  }
  switch (action.type) {
    case 'submit': {
      // Which record: the list row the button is in, or the row that opened this screen.
      const offerId = source.record?.['offer.id']
      void redeem(typeof offerId === 'string' ? offerId : action.params?.offerId)
      break
    }
    case 'share':
      void navigator.share({ url: location.href })
      break
    case 'navigate': {
      // A screen this document does not have: it is yours. Match the name against YOUR routes —
      // never build a path from `action.params.screenId`, it is text from a document.
      const route = ROUTES[action.params?.screenId ?? '']
      if (route !== undefined) router.push(route)
      break
    }
    case 'navigate.back': // "back" on the first screen: the user leaves the flow
      router.push('/app')
      break
    default:
      console.warn('unhandled layout action', action.type, 'from', source.nodeId)
  }
}

Pass it as onAction={handleAction}.

Rules to keep:

  • Open only action.standard. action.params.url of an action marked invalid is a raw string from the document — never open it.
  • Which domains may be opened is your decision. The check is about the form of an address (an https:// host name, no credentials, no IP addresses); it cannot tell an internal name from a public one. Keep a list.
  • An app link is performed only with a scheme you named in deeplinkSchemes. Its path and query stay untrusted and are judged by your router.
  • Do not build a route from action.params.screenId. Match it against your own list, as ROUTES does above.
  • A double click is two actions. Protect a payment from a repeat on your side. A held key is one press.
  • An action your app does not know is ignored, not thrown on. The screen may be newer than the app.

To put the document's screens into your own URLs, see onNavigate and entry in the package README.

6. Accept what the user wrote#

A screen can ask: text fields, switches, checkboxes, a choice group, and buttons that write a value (setValue, toggleValue). Each writes to a data path. The renderer keeps the written value for the lifetime of the flow, so conditions and bound texts on the same screen follow the user at once, and it calls onWrite(path, value) at the same moment.

The sample document of step 2 has no inputs. To try this step before you have a screen of your own, take fixtures/reward-catalog-screens.json of the contract: a choice group that writes item.selectedId, and a details screen that reads item.name and item.price.

The path is named by the document, so treat it as input. Say which paths may be written with writablePaths. examples/security.tsx:

/** The only paths this screen may write to — from your project's data dictionary. */
const WRITABLE = ['item.selectedId', 'survey.answer'] as const

export function Survey({ layout, initial }: { layout: UiDocument; initial: DataScope }) {
  const [data, setData] = useState<DataScope>(initial)
  return (
    <HotlayoutScreen
      document={layout}
      data={data}
      writablePaths={WRITABLE}
      onWrite={(path, value) => {
        // A FLAT map keyed by the path — never `lodash.set(state, path, value)`:
        // the path is chosen by the document, and a deep setter turns it into a property walk.
        setData((current) => ({ ...current, [path]: value }))
      }}
    />
  )
}
Written by onWrite receives
a text field a string, on every change; a cleared field writes ""
a number field (valueType: 'number') a number, or "" when the text is not a number
a switch, a checkbox, toggleValue a boolean
a choice group the chosen option, a string
setValue a string, a boolean or a number, as the action says
  • With writablePaths, a write to any other path never reaches onWrite; it is reported as ignored-input. Without it the document may name any path made of dot-separated identifiers.
  • Every written value is user input. Check it on your backend. There are no validation rules in a document: the error of a field is a string you put into data.
  • onWrite is called on every keystroke. Throttle what you send over the network yourself.

After a write, supply the data that depends on it#

The renderer writes the value and tells you. It does not fetch or invent the data that follows from the choice — that is the host's job.

The typical case: a catalog screen with a choice group bound to item.selectedId, and a "Details" button that opens a screen bound to item.name, item.price, item.image. The user picks the second product and presses the button. The renderer wrote item.selectedId and opened the details screen — and that screen reads item.name from your data. If data still holds nothing (or the first product), the screen shows fallback texts (or the first product), and nothing fails or is reported: a fallback is a legitimate state.

So on onWrite, put the record behind the choice into data. examples/docs/after-write.tsx:

import { useState } from 'react'
import { HotlayoutScreen } from '@hotlayout/react'
import type { DataScope, UiDocument } from '@hotlayout/react'

/** `catalog` maps a product id to its fields in the flat form of the data dictionary: `{ 'item.name': 'Bottle', 'item.price': 300 }`. */
export function Shop({ layout, catalog }: { layout: UiDocument; catalog: Readonly<Record<string, DataScope>> }) {
  const [selectedId, setSelectedId] = useState('')
  return (
    <HotlayoutScreen
      document={layout}
      // The renderer wrote the id. The host supplies the record the id stands for.
      data={{ ...catalog[selectedId], 'item.selectedId': selectedId }}
      writablePaths={['item.selectedId']}
      onWrite={(path, value) => {
        if (path === 'item.selectedId' && typeof value === 'string') setSelectedId(value)
      }}
    />
  )
}

The same holds for anything derived from input: a price that depends on a chosen plan, a list filtered by a switch, an error text for a typed phone number. A screen opened from a list row does not need this — the row carries its record itself (One screen, many records).

7. Analytics and screen views#

An action may carry track — { event, params? }. It arrives in onTrack once per press and before anything else, also for a button whose action is none. onScreenChange is the screen-shown event. The renderer sends nothing anywhere itself.

examples/actions.tsx (analytics is your own client):

export function Tracked({ layout }: { layout: UiDocument }) {
  return (
    <HotlayoutScreen
      document={layout}
      onAction={handleAction}
      // A button with `action.track`: once per press, BEFORE the action itself (and for `none` too).
      onTrack={(event, source) => {
        analytics.track(event.event, { ...event.params, node: source.nodeId, screen: source.screenId })
      }}
      // One call per screen shown: the first one, a forward step, a return. Not on re-renders, not on the server.
      onScreenChange={(screen) => {
        analytics.track('screen_view', { screen: screen.screenId, cause: screen.cause, depth: String(screen.depth), entry: screen.entry ?? '' })
      }}
    />
  )
}
cause When
entry the first screen; also after you changed entry or the document
forward navigate put a screen on top — a modal sheet too
back navigate.back, or closing a sheet: the screen under it is shown again
switch navigate.section made the named screen the only one in the stack

The event is not fired on a re-render, on new data, on an action handed to you, or on the server. Its fields are listed in the package README.

8. Next.js and server rendering#

renderToString and streaming work: nothing touches window on import, and the stylesheet and the theme variables are part of the markup the server sends.

No wrapper is needed in the App Router. The package marks its component as a Client Component itself, so <HotlayoutScreen> goes straight into a Server Component. The helpers — loadDocument, loadTheme, checkDocumentVersion, resolveTheme — are not client code and can be called on the server. Reading the files on the server is also one of the ways around the browser restriction.

The page below is for Next.js 16. examples/docs/next.tsx:

// app/rewards/page.tsx — a Server Component. No "use client", no wrapper of your own.
import { Suspense } from 'react'
import { connection } from 'next/server'
import { HotlayoutScreen, checkDocumentVersion, loadDocument, loadTheme } from '@hotlayout/react'

import { DOCUMENT_URL, THEME_URL } from './addresses' // the direct addresses, or addresses of your backend

export default function RewardsPage() {
  // The page shell is static. The screen is rendered per request and streams in; until then the user sees the fallback.
  return (
    <Suspense fallback={<p role="status">Loading rewards…</p>}>
      <PublishedRewards />
    </Suspense>
  )
}

async function PublishedRewards() {
  // Per request, never at build time: what a manager published is on the page at the next load.
  await connection()
  const [layout, theme] = await Promise.all([loadDocument(DOCUMENT_URL), loadTheme(THEME_URL).catch(() => undefined)])
  if (checkDocumentVersion(layout.schemaVersion).kind === 'incompatible') return <p role="alert">This screen needs a newer app.</p>
  // `now` is read once on the server and travels to the browser with the props: both count "days left" from the same moment.
  const now = new Date().getTime()
  return <HotlayoutScreen document={layout} theme={theme} locale="en" timeZone="Europe/Kyiv" now={now} style={{ minBlockSize: '100dvh' }} />
}

./addresses is a file of yours with the two addresses — the direct ones from step 3, or addresses of your backend.

When the document is read#

A page that awaits a fetch at its top level is where a new Next.js app goes wrong, so the example decides explicitly when the document is read:

  • await connection() makes the component render on every request. Nothing is read while the app is built, so what a manager publishes is on the page at the next load — no rebuild, no redeploy. Without it, an app that has cacheComponents off renders the page once, during next build, and the document stays as it was at that moment.
  • <Suspense> gives the page a shell to send at once. An app made by create-next-app 16 has cacheComponents: true, and there an uncached read outside <Suspense> is reported in development ("uncached data … accessed outside of <Suspense>"), and so is the clock read for now. Inside the boundary, after connection(), both are per-request work that streams in.
  • The fallback is what the user sees until the screen arrives — and for good if scripts are off. Make it a real placeholder of the page, not an empty element.

The price of "always current" is two requests to the storage per page load. When that is too much, serve the document from your own backend, which stores what the webhook delivers: the page reads an address of yours, and you decide how long an answer lives.

The same page works with cacheComponents on and off.

Callbacks#

A Server Component cannot pass callbacks — that is React's rule. When you need onAction, onWrite and the rest, the screen goes into a Client Component of yours, and PublishedRewards above returns <Rewards layout={layout} theme={theme} /> instead of the screen itself. examples/next.tsx:

// app/rewards/Rewards.tsx — starts with 'use client': callbacks are functions,
// and functions cannot be passed from a Server Component to a Client Component.
export function Rewards({ layout, theme }: { layout: UiDocument; theme: ThemeContract | undefined }) {
  return (
    <HotlayoutScreen
      document={layout}
      theme={theme}
      timeZone="Asia/Dubai"
      onAction={(action) => {
        if (action.type === 'submit') void redeem(action.params?.offerId)
      }}
    />
  )
}

Four things to set or know when you render on a server:

  • Set timeZone whenever a screen with dates is rendered on a server. A server usually runs in UTC and the browser does not; the same moment formatted twice gives two texts and a hydration mismatch.
  • Set now for the same reason when the document counts days: read the clock once on the server, after connection(), and pass it down, as in the first example.
  • onReport fires in the browser. On the server use checkDocumentVersion and resolveTheme(theme).reports to log fallbacks.
  • The width class on the server is compact unless you pass initialWidthClass. One document serves every width; the renderer measures its own root element in the browser.

9. Versions: what a user sees#

The rule is the contract's: the major must match; a minor only adds optional things.

examples/versions-and-loading.tsx:

checkDocumentVersion('9.0.0') // { kind: 'compatible' }
checkDocumentVersion('9.99.0') // { kind: 'newer-minor', documentVersion: '9.99.0' } — drawn, the unknown is skipped
checkDocumentVersion('10.0.0') // { kind: 'incompatible', reason: 'major 10 is not readable by a major-9 reader', relation: 'document-newer' }
console.log(`this build reads schema ${READER_SCHEMA_VERSION}`)
Document What the user sees
same major, same or older minor The screen.
same major, newer minor The screen. Widgets this build does not know draw their children, unknown tokens and fields are skipped; onReport gets newer-minor-document. See the compatibility policy.
another major, newer or older Not drawn. By default a short role="alert" message: "Update the app to see this screen." for a newer major, "This screen is not available right now." otherwise.

Replace the message with your own:

export function Guarded({ layout }: { layout: UiDocument }) {
  return (
    <HotlayoutScreen
      document={layout}
      renderIncompatible={(verdict) =>
        verdict.relation === 'document-newer' ? (
          <p role="alert">Please update the app to see this screen.</p>
        ) : (
          <p role="alert">This screen is not available right now.</p>
        )
      }
      onReport={(report) => {
        if (report.kind === 'incompatible-document') console.error('layout refused', report.documentVersion, report.readerVersion, report.reason)
      }}
    />
  )
}

HotlayoutScreen applies the rule itself. Call checkDocumentVersion when you want to decide before rendering — typically to keep the previous version of a screen on screen instead of the refusal, as useLayout does in step 3.

When the builder moves to a new schema major, update the package in your app before screens are published again from the new builder.

Language#

Pass the user's locale as the platform gives it — navigator.language, the Accept-Language winner. Translations live in the document, and the renderer finds the right one itself: ar-EG finds the document's ar, never the other way round. The writing direction follows the language that is actually shown and is set on the screen's root only.

examples/theme-and-fonts.tsx:

export const arabic = (
  <HotlayoutScreen
    document={layout}
    locale={navigator.language} // the user's locale as the platform gives it: `ar-EG` finds the document's `ar` — translations, Egyptian date formats, direction (rtl)
    timeZone="Africa/Cairo" // dates are written in this zone — on the server and in the browser alike
    labels={{ back: 'رجوع' }} // the accessible name of the back arrow is the renderer's text, so it is yours to translate
  />
)

// A language the document has no translation for is shown in the document's own language,
// and the direction follows what is actually shown. Force it only if you know better:
export const forced = <HotlayoutScreen document={layout} locale="en" dir="rtl" />

The back arrow's accessible name (labels.back) is the renderer's own text, so it is yours to translate. Details are on the localization page.

The document is untrusted input#

The document and the theme arrive over a network, and whoever can publish them can change what your users see. The builder validates both, but the renderer does not rely on that: every value is parsed again where it becomes CSS, an address or a key.

The package guarantees You decide
A value never becomes CSS it was not meant to be Which image hosts are acceptable: resolveImageUrl (default: absolute https: only)
Nothing is drawn or clicked outside the root element Which paths the document may write: writablePaths
A malformed document does not take your app down: the screen is replaced by a neutral notice Which addresses and app-link schemes are opened: your onAction
onWrite and source.record never carry __proto__, constructor or prototype keys Whether this user may do what the screen asked: your backend

The full model, the Content-Security-Policy to use and the limits (!important rules of your own page reach inside the screen) are in the package README.

Fonts#

The package ships no font files. The theme names the families (fontFamilies.body.primary, fontFamilies.heading.primary), and the renderer writes them into font-family; making them available is the page's job, as with any CSS. Without the font nothing fails, but line breaks and the width of buttons differ from what was designed — check the font first when a screen looks "almost right". The built-in theme asks for Inter 400, 500, 600 and 700.

What to check when nothing shows up#

Work down the list; each step rules out one layer.

  1. TypeError: Failed to fetch, while curl of the same address answers 200. That is CORS: see A browser cannot read our storage from your domain.
  2. HotlayoutLoadError with status 404. Nothing is published at the address: a wrong id, or a draft that was saved but never published.
  3. HotlayoutLoadError "is not JSON" with status 200. The server answered with a page instead of the file. This is what a development server (and any single-page-app hosting) does for an address it does not know: it returns index.html with code 200. Open the address in a browser tab — you should see JSON with schemaVersion and screens.
  4. "Loading…" that never ends. The load failed and the code swallowed the error. Keep the states apart, as useLayout does in step 3, and read the console.
  5. The page is empty or a few pixels high, or the screen is narrower than the page. Give the root a height (style={{ minBlockSize: '100dvh' }}) and remove the styles of the app template (step 2).
  6. A short message instead of the screen. The document is of another major: read the incompatible-document report and update the package, or publish the document again from the builder.
  7. The screen is there, the values are not. Bindings show their fallbacks: the keys of data must match the paths of the data dictionary exactly, and data must be flat ({ 'user.points': 1 }, not { user: { points: 1 } }).
  8. A list is empty. The list is not in collections under the collection name, or its records use full paths (item.name) instead of field names (name).
  9. The details screen shows the wrong record or fallback texts. See After a write, supply the data that depends on it.
  10. A block is missing. Its visibleIf condition did not hold; a missing value counts as empty.
  11. Colors look wrong. Look for a theme-fallback or invalid-theme-values report.
  12. A hydration mismatch on a screen with dates. Set timeZone and now — step 8.
  13. Next.js reports "uncached data … outside of <Suspense>" or an unstable Date. See When the document is read.
  14. Buttons look squared or restyled. A global !important rule of your page reaches inside the screen.

Every fallback the renderer takes is reported: pass onReport, or read the browser console — without a handler the reports go to console.warn.

Where to go next#