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:
- Install
- Draw your first screen
- Load the published document and theme
- Supply data
- Handle actions
- Accept what the user wrote
- Analytics and screen views
- Next.js and server rendering
- 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/reactNext.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.cssinsrc/main.tsx; it gives#roota fixed width, side borders and centered text, so the screen is not as wide as the page. Replace the content of that file withbody { 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, notdocument. Inside a componentdocumentwould shadow the browser's owndocument. - No theme is not an error. Without
themethe 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=mediaReplace <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?)andloadTheme(url, options?)resolve to the parsed JSON. A failure is aHotlayoutLoadErrorwithurlandstatus: the request did not happen (nostatus), 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;
HotlayoutScreenis what refuses a document it cannot draw. - Options:
signalcancels the request;bypassCache: trueadds a unique query parameter so that no cache in between answers;fetchreplaces the globalfetch. - No custom request headers are sent, on purpose: a header such as
Cache-Controlturns the request into a preflighted one. This is whybypassCachechanges 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
fallbackwritten 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,
0andfalseare 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 insource.record(item.id) with the rest of the row. The standard dictionary has noitem.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.recordas 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.urlof an action markedinvalidis 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, asROUTESdoes 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 reachesonWrite; it is reported asignored-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
errorof a field is a string you put intodata. onWriteis 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 hascacheComponentsoff renders the page once, duringnext build, and the document stays as it was at that moment.<Suspense>gives the page a shell to send at once. An app made bycreate-next-app16 hascacheComponents: true, and there an uncached read outside<Suspense>is reported in development ("uncached data … accessed outside of<Suspense>"), and so is the clock read fornow. Inside the boundary, afterconnection(), 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
timeZonewhenever 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
nowfor the same reason when the document counts days: read the clock once on the server, afterconnection(), and pass it down, as in the first example. onReportfires in the browser. On the server usecheckDocumentVersionandresolveTheme(theme).reportsto log fallbacks.- The width class on the server is
compactunless you passinitialWidthClass. 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.
TypeError: Failed to fetch, whilecurlof the same address answers 200. That is CORS: see A browser cannot read our storage from your domain.HotlayoutLoadErrorwith status 404. Nothing is published at the address: a wrong id, or a draft that was saved but never published.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 returnsindex.htmlwith code 200. Open the address in a browser tab — you should see JSON withschemaVersionandscreens.- "Loading…" that never ends. The load failed and the code swallowed the error. Keep the states apart, as
useLayoutdoes in step 3, and read the console. - 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). - A short message instead of the screen. The document is of another major: read the
incompatible-documentreport and update the package, or publish the document again from the builder. - The screen is there, the values are not. Bindings show their fallbacks: the keys of
datamust match the paths of the data dictionary exactly, anddatamust be flat ({ 'user.points': 1 }, not{ user: { points: 1 } }). - A list is empty. The list is not in
collectionsunder the collection name, or its records use full paths (item.name) instead of field names (name). - The details screen shows the wrong record or fallback texts. See After a write, supply the data that depends on it.
- A block is missing. Its
visibleIfcondition did not hold; a missing value counts as empty. - Colors look wrong. Look for a
theme-fallbackorinvalid-theme-valuesreport. - A hydration mismatch on a screen with dates. Set
timeZoneandnow— step 8. - Next.js reports "uncached data … outside of
<Suspense>" or an unstableDate. See When the document is read. - Buttons look squared or restyled. A global
!importantrule 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#
- The full API of the component, screen layout, forms and accessibility — the package README.
- What your backend serves and stores — the backend guide.
- Every widget and property — the widget reference.