Placements
A placement is a named spot in your app, such as home.banner or promo.raffle-button. Your developer writes the name into the app once. After that a manager decides in the admin which published document stands there — a banner, a raffle, a survey — and can change it any day without a release of the app.
This page is for the app developer and the backend developer. The manager's side is the Places section of the admin: a table of names, with a published document (and, optionally, an entry screen of it) chosen for each.
The placement map#
The decision of the project is one small JSON file, the placement map:
{
"placementsVersion": "1.0.0",
"publishedAt": "2026-10-23T09:15:00.000Z",
"placements": {
"home.banner": { "documentId": "d-raffle", "entry": "raffle" },
"promo.raffle-button": { "documentId": "d-raffle" },
"profile.top": { "documentId": null }
}
}| Part | Meaning |
|---|---|
| name (the key) | lowercase Latin letters and digits in dot-separated segments, starting with a letter, at most 64 characters: home.banner |
documentId |
the id of a published document; null declares the placement but leaves it empty on purpose |
entry |
optional: a named entry point of that document to start from. Without it the document starts on its first screen |
placementsVersion |
version of the placement contract, semver |
publishedAt |
when the map was published, for people to read |
A map holds up to 200 placements and at most 64 KB. A placement can also hold a schedule of documents — see Scheduled documents. The map is its own contract, placements.json, versioned apart from the screen schema and the theme; a reader of another major version reports the map as unsupported and draws nothing.
There is one map per project, and it has no draft: the moment a manager publishes the places, the apps read the new map. Publishing is checked first: every document in the map must be published, and every named entry must exist in it.
What a reader does with a placement#
The reader looks the name up in the map and draws the document it finds. If there is nothing to draw, it draws nothing at all (a zero-size box) and tells your code why, so that you can fold the frame around the banner.
| Report | Meaning |
|---|---|
empty |
the placement is in the map with documentId: null |
unknown |
the map has no such name — most often a typo in the app, or a placement the manager removed |
unreadable |
the map could not be read, or the entry of this one placement is broken |
documentMissing |
the document of the placement was unpublished (the address answers 404) |
documentUnreadable |
the document could not be read, or is of another major version |
entryMissing |
the document is drawn, from its first screen, because the entry point named in the map is gone. Someone has to fix the setup in the admin |
The map is held like a published document: the network first, then the last known map the device saw, then a copy bundled with the app. Only a fresh, readable map from the network becomes the last known one. A placement that is missing from a fresh map is unknown — it is never taken from an older copy, so a placement removed in the admin disappears from the phones. If the project has never published a map (404), every placement is unknown.
Both readers also tell your code the state of the map itself (fresh, offline, corrupt, unsupported, notPublished), so that "the network is silent" and "the map is broken" are not the same story in your logs.
Scheduled documents#
From version 1.1.0 of the placement contract a placement can also hold a schedule: a queue of documents, each shown for its own window of time. The manager queues them ahead — a month at a time is typical — and the phone switches the placement by itself when a window starts and when it ends.
{
"placementsVersion": "1.1.0",
"placements": {
"home.banner": {
"documentId": "d-everyday",
"schedule": [
{ "documentId": "d-black-friday", "entry": "sale", "from": "2026-11-27T22:00:00Z", "until": "2026-11-30T22:00:00Z" },
{ "documentId": "d-new-year", "from": "2026-12-24T22:00:00Z", "until": "2027-01-01T22:00:00Z" }
]
}
}
}documentId and entry of the placement are its permanent content; null there means that outside the windows the placement is empty. At any instant the placement shows the scheduled document whose window lasts, and its permanent content otherwise.
| Part | Meaning |
|---|---|
schedule |
optional; one to 32 scheduled documents. Without it the placement always shows its permanent content |
documentId, entry |
the document of this window and, optionally, its entry point; the same forms as above. documentId: null is a pause: the placement is empty for this window (without an entry) |
from |
the first instant of the window, included |
until |
the first instant after the window, excluded: at until the permanent content is back |
The instants are UTC with a Z, to the second: 2026-11-27T22:00:00Z. There is no time zone in the file. The admin works in the time zone of your organisation and writes the instants for you: "until 30 November included" in Kyiv becomes until = 1 December 00:00 Kyiv time, which is 2026-11-30T22:00:00Z. Summer time is accounted for on the date of each instant.
Two windows of one placement never overlap in a published map (windows that touch, one until equal to the next from, are fine); windows of different placements are independent. The whole map, schedules included, still fits in 64 KB.
Taking a banner down for the weekend#
A pause hides the placement for its window and gives the permanent content back when the window ends. This map takes the everyday banner down from Friday 22:00 to Sunday 22:00 UTC (Saturday 00:00 to Monday 00:00 in Kyiv), and the app shows nothing there for those two days:
"home.banner": {
"documentId": "d-everyday",
"schedule": [
{ "documentId": null, "from": "2026-12-04T22:00:00Z", "until": "2026-12-06T22:00:00Z" }
]
}During the pause the reader reports empty, exactly as for a placement with documentId: null, so your code folds the frame the same way (see When the placement is empty). A pause takes part in the schedule like any other window: it may sit right next to a scheduled document, but not overlap one.
The phone checks the time#
The reader decides from the device clock, not from the network: a window opens on time even without a connection, and a map read a day earlier already knows tomorrow's banner. It does not poll for this either — it redraws at the next start or end of a window, and checks again whenever it reads a new map and when the app returns to the foreground.
The price is the device clock itself. A phone whose clock is set wrong, by hand or by a dead battery, shows the scheduled banner at the wrong time — before the sale started or after it ended. If showing a document early or late would cost you something (a price that is not valid yet, a promise to a customer), do not rely on the window alone: check the condition on your backend when the customer acts.
Older readers#
A reader written for version 1.0.0 of the placement contract reads a 1.1.0 map without errors, ignores schedule and always shows the permanent content: a scheduled document is never shown on such a phone. The changelog of each reader package says from which version it reads schedules.
When the placement is empty#
When there is nothing to show — documentId: null outside any window, a pause, or any of the reports above — the reader draws a zero-size box and reports why. Keep the placement widget in your layout (it has to stay mounted to hear when a document comes back) and remove only what is yours around it: the padding, the divider, the title of the section.
In Flutter, the placement already gives SizedBox.shrink() when it shows nothing; make your own spacing depend on the event:
bool _bannerShown = false;
Widget build(BuildContext context) {
return Padding(
padding: _bannerShown ? const EdgeInsets.only(bottom: 16) : EdgeInsets.zero,
child: HotlayoutPlacement(
name: 'home.banner',
projectBase: Uri.parse(projectBase),
theme: theme,
onPlacementEvent: (name, event) => setState(() => _bannerShown = event.show == PlacementShow.document),
),
);
}In React the same is done with onPlacementStatus, as in the example below: the <section> around the placement is hidden while status is not document, and <HotlayoutPlacement> stays mounted inside it.
Where the files are#
The map lives next to the theme of the project:
orgs/{orgId}/projects/{projectId}/placements/published.jsonand the document of a placement is the usual published file of that document, orgs/{orgId}/projects/{projectId}/documents/{documentId}/published.json.
Direct address#
The direct address of the map has the same form as the addresses in Delivery options. Replace <orgId> and <projectId> with your ids and change nothing else:
https://firebasestorage.googleapis.com/v0/b/ui-builder-dd4af.firebasestorage.app/o/orgs%2F<orgId>%2Fprojects%2F<projectId>%2Fplacements%2Fpublished.json?alt=mediaIt is public like the document: no token, and the only protection is that the ids cannot be guessed. A project that has never published its places answers 404. The address of a document is the one in Delivery options; the reader gets the id from the map.
Webhook#
With the webhook on, publishing the places sends a second kind of request to the same address: x-webhook-event: placements.published, with the map in the placements key and a generation that grows with every publication. Store the map as it is and order publications by generation. The format is in The placement map body; the documents themselves arrive in their own document.published requests.
React and Flutter ask for addresses differently#
The two readers have the same behaviour and different ways to be told where the files are.
React (@hotlayout/react) |
Flutter (hotlayout_flutter) |
|
|---|---|---|
| What you give | mapUrl — the address of the map; documentUrl(documentId) — a function from a document id to the address of its published file |
projectBase — the folder of the project |
| Layout it expects | none: the two addresses are yours entirely | <projectBase>/placements/published.json and <projectBase>/documents/<documentId>/published.json |
projectBase forms |
— | a plain folder (https://host/orgs/o1/projects/p1) or a Firebase Storage object folder (https://…/v0/b/<bucket>/o/orgs%2Fo1%2Fprojects%2Fp1) |
| Headers it sends | none | If-None-Match (an ETag revalidation) |
To make one backend fit both, serve the project folder with the layout Flutter expects and point React at it:
GET <BASE>/placements/published.json the placement map
GET <BASE>/documents/<documentId>/published.json the published documentIn React mapUrl is then <BASE>/placements/published.json and documentUrl is (id) => <BASE>/documents/${id}/published.json; in Flutter projectBase is <BASE>. The documentId is always a safe path segment (letters, digits, ., _, -; at most 64 characters), so it can go into an address as it is. If your backend serves several versions of a document by renderer version (see Backend guide), that choice stays inside the handler of the second address.
Browser and CORS. React sends no custom headers. Flutter sends If-None-Match, which in a web build makes the browser send a CORS preflight request first: your backend must answer it, allow the If-None-Match header and expose ETag. Native Flutter apps are not browsers and are not affected. A web page on your own domain cannot read our storage directly in either case — see Reading from a browser, so for a web app serve the folder from your backend.
A banner on the home screen in React#
// Your backend serves the project folder with the same layout as our storage:
// <BASE>/placements/published.json
// <BASE>/documents/<documentId>/published.json
const BASE = 'https://api.example.com/hotlayout'
function onBannerAction(action: HotlayoutAction, source: ActionSource): void {
// The banner is one small document; what a press does is YOUR decision, as on any screen.
// `deeplink` and `openUrl` arrive checked; the helper runs only what you allow.
const done = runStandardAction(action, {
deeplinkSchemes: ['myapp'],
openDeeplink: (url) => {
router.push(url)
},
})
if (!done) console.warn('banner action not handled', action.type, 'from', source.nodeId)
}
export function HomeBanner() {
// Fold the frame while nothing stands in the place, so the home screen has no hole.
const [shown, setShown] = useState(false)
const onPlacementStatus = (event: PlacementStatusEvent) => setShown(event.status === 'document')
return (
<section hidden={!shown} style={{ maxHeight: 160, overflow: 'hidden' }}>
<HotlayoutPlacement
name="home.banner"
mapUrl={`${BASE}/placements/published.json`}
documentUrl={(documentId) => `${BASE}/documents/${documentId}/published.json`}
theme={theme}
onAction={onBannerAction}
onPlacementStatus={onPlacementStatus}
/>
</section>
)
}<HotlayoutPlacement> takes every prop of <HotlayoutScreen> except document and entry (theme, data, locale, actions), and draws the document as a screen. onPlacementStatus is called on the first answer and on every change after it, including the way back: a banner you folded opens again when a document is put into the placement. usePlacement(name, options) is the same without the drawing, for the case when you want to place the document yourself. The rest — refresh when the tab returns, polling, the last-known store, the bundled map — is in the React reference.
A banner on the home screen in Flutter#
return HotlayoutPlacement(
name: 'home.banner',
projectBase: Uri.parse(projectBase), // the project folder in your storage
theme: theme,
data: data,
onAction: onAction,
onPlacementEvent: (name, event) => log('$name: ${event.show.name} ${event.report?.name}'),
);The widget polls the map every refreshInterval (five minutes by default, null for never) and when the app returns to the foreground. onPlacementEvent hears every change of what the placement shows, the way back included, so a frame folded on documentMissing can be unfolded. For a source of your own — a transport that does not send If-None-Match, a map bundled with the app, a store that keeps the last known map between launches — see the placements chapter of the Flutter reference.
What makes a good banner#
A document in a placement is a normal document: it can have a header, several screens and a theme of its own look. For a banner, make a small one: a single screen without an app bar, as tall as its content, with the press on the whole card. In the builder, create it as a Block for a place instead of a Screen; the editor then shows it inside a mock app. Keep the frame in your app short (a fixed maxHeight, as in the example) so that a document that is taller than planned cannot push the home screen away.
A press in the banner goes the same way as on any screen: through onAction (onAction: in Flutter) to your code, which decides what it means for your navigation — see Handle actions and, for Flutter, Handle actions. deeplink and openUrl arrive already checked and performed only by what you allow; a custom action name is a name you agreed with the manager.
Self-hosted#
If you run the editor on your own storage (Self-hosted), the placement map is your file: the editor only reads it, to warn at publishing that a placement points to an entry point the new version no longer has. Writing the map, serving it to the apps and sending your own notification when it changes are yours to build; the sample server of that page reads placements/published.json and writes none.
Versions#
The placement contract is versioned apart from the screen schema; its version is in x-placementsVersion of placements.json and in placementsVersion of the map. A reader of the same major reads any minor and ignores a field it does not know. Placements are drawn by @hotlayout/react and hotlayout_flutter; the changelog of each package says from which version. The quickstarts for the native iOS and Android readers do not cover placements.