Build a block
Ship a block your app owns, so merchants can drag it onto a page in the editor and it keeps rendering from your app afterwards.
A block is what your app contributes to a merchant's page editor: a pre-built piece of page, with a small set of controls the merchant is allowed to change on it. They drag it from the elements panel like any other element; it renders on their storefront; and when you ship a fix, every store that installed your app gets it.
This page assumes you already have an app. If not, start there — a block belongs to a version of an app, and the version is what gets reviewed.
1. What a block is, and is not
A block is a subtree of the platform's own elements, plus a declared trait bar.
It is not markup. You write no HTML, CSS or template. You describe a tree
of nodes — flex-block, heading, button, image — with exactly the
properties a merchant's own elements have, and the platform renders it.
That constraint shapes everything you build here, so here is why. The platform renders every page twice: once in the browser, on the editor's canvas, and once in Go when the page is published. The two renderers must produce byte-identical HTML — the invariant the whole product stands on. Markup only one of them knows how to draw breaks it. So a block is expressed in the vocabulary both renderers already share, and the price is that you cannot invent an element. What you get back: your block behaves exactly like a native one — it follows the merchant's breakpoints, theme and fonts, and stays correct when any of them change.
If your block needs to do something — a countdown, a carousel, a fetch — that is an island, in §6.
2. The manifest
One PUT replaces the whole of your version's contribution to the elements
panel. The whole set is what a reviewer approves, so there is no per-block
route: send everything, every time.
PUT /api/orgs/{orgId}/apps/{appId}/versions/{versionId}/blocks
Authorization: Bearer <your user token>
Content-Type: application/json
A complete, valid manifest:
{
"blocks": [
{
"key": "loyalty-badge",
"name": "Loyalty badge",
"icon": "award",
"rootId": "wrap",
"nodes": [
{
"id": "wrap",
"type": "flex-block",
"children": ["title"],
"props": { "style": { "gap": "8px" }, "config": {}, "specials": {} }
},
{
"id": "title",
"type": "heading",
"props": { "style": {}, "config": {}, "specials": { "text": "Members save 10%" } }
}
],
"slots": [{ "name": "title", "nodeId": "title" }],
"traits": [
{
"key": "look",
"label": "Appearance",
"attributes": [
{ "widget": "text_color", "slot": "title", "target": "style", "writeKey": "color" }
]
}
]
}
]
}
| field | what it is |
|---|---|
key |
your id for this row, a lower-case slug. It is half of the reference a merchant's page stores, so once shipped it must not change. |
name |
what the merchant reads in the elements panel. |
icon |
the icon in the elements panel. A name that does not resolve falls back to a default — the one field not checked against a vocabulary. |
rootId |
which node gets dropped. Stated, never inferred. |
nodes[] |
the subtree. id is yours and means something only inside the block; ids on the merchant's page are minted at drop time. |
nodes[].type |
an element type the platform renders. Checked. |
nodes[].props |
the node's own style / config / specials, passed through verbatim. |
slots[] |
names an inner node so a trait can point at it. |
traits[] |
the merchant's controls — §3. |
Limits: 24 blocks per version, 1 MiB for the whole document.
Where the vocabulary comes from. type and widget are checked against
what the editor actually ships, not against a list in this guide. Two
consequences: a name that is valid today may be renamed or removed by a later
editor release — a saved draft is re-checked at submit for exactly this
reason; and this page lists no element types, because such a list would be
wrong within a release. The error message says where you went wrong, by name.
3. The trait bar
The trait bar is the whole of the merchant's editing surface on your block. What you declare, they can change; everything else is yours.
An attribute has four parts:
{ "widget": "text_color", "slot": "title", "target": "style", "writeKey": "color" }
widget— which control the editor renders, checked against the editor's widget vocabulary.slot— which node it edits, by the name you gave inslots[]. The reserved slot$rootis the block's root node.target— one ofstyle,config,specials.writeKey— the key that widget writes.
A declaration opens a NAMESPACE, not a key. Declaring
{ target: "style", writeKey: "color" } on a slot opens all of that node's
style to the merchant, not just color. That is deliberate: most widgets
write more than one key — a border writes four sides, a shadow writes offset,
blur and colour — and per-key permission would make those widgets half-work.
writeKey stays required because it records what the control is for and is
checked against the vocabulary. The consequence: if you are not comfortable
with the merchant changing anything in a node's style, do not declare a
style attribute on that node — split the design into more nodes and
declare exactly what you mean to give.
| target | holds |
|---|---|
style |
per-breakpoint CSS — colours, sizes, spacing |
config |
per-breakpoint data that is not CSS |
specials |
content and identity, base-only — text, the chosen tag |
4. What the merchant can and cannot change
A dropped block is sealed. The merchant cannot select, edit, move, duplicate or delete anything inside it, and cannot paste into it. What they can do is delete the block itself, and change whatever your trait bar opens.
Their settings survive your updates. A merchant's changes are stored on the reference — the one node their page keeps — keyed by slot, and re-applied every time the block is materialised. So when you ship a new version, the colour they picked is still the colour they picked. Two consequences:
- a setting for a control you remove in a later version lies dormant — not deleted, not applied; add the control back and the setting returns with it.
- their page stores a reference to your block, not a copy. No fork of your markup lives anywhere. That is what makes "ship a fix and every client site is fixed" true — and why the manifest goes through review.
5. Versions
Review attaches to a version, never to your app, because review pins a payload: attached to the app, you could change the very thing the reviewer approved.
Once a version is approved:
- an approved version with the same scopes moves every install over automatically. The merchant does nothing and consents to nothing — the scopes did not change. This is the point of the whole design.
- a version asking for MORE than the merchant granted stops and asks, on their consent screen.
- the merchant can roll back to any earlier approved version, and is told beforehand which of your blocks that version does not contribute — a rollback that would remove a region of their live page says so first.
Ship a version and find it broken? Withdraw it:
POST /api/orgs/{orgId}/apps/{appId}/versions/{versionId}/withdraw
DELETE /api/orgs/{orgId}/apps/{appId}/versions/{versionId}/withdraw (restore)
Withdrawal is deliberately asymmetric: nobody new lands on that version, and every store already on it keeps running it. Yanking a live block out of a merchant's page is a worse remedy than the disease. To move them off it, ship a fixed version — they move automatically if the scopes match.
When your update reaches a storefront. Immediately in the merchant's editor. On their published pages, at the next publish — the platform re-renders every live page still serving an old build of your block the next time the merchant publishes anything. It does not republish their site for you.
6. Islands: making a block do something
An island is a JavaScript module your app ships. A node in your manifest names it, and on the published page that node is hydrated by your code.
Three steps.
Declare the name. Just the name — the code comes later, by another door.
PUT /api/orgs/{orgId}/apps/{appId}/versions/{versionId}/islands
{ "islands": [{ "name": "countdown" }] }
Point a node at it, with the node's island field in your block manifest:
{ "id": "wrap", "type": "flex-block", "island": "countdown", "children": ["title"] }
Upload the module. The body is the JavaScript itself — no multipart envelope, because this is a build step, not a file dialog.
POST /api/orgs/{orgId}/apps/{appId}/versions/{versionId}/islands/module?name=countdown
Content-Type: text/javascript
export default class { … }
Your module registers itself under the name it was given:
window.WB.register('app.app_1a2b3c4d.countdown', MyIsland);
You will send that request on every edit.
sb devwatches the file and resends on every save — the samePOST, and you still refresh the page, because an island's code is served from the platform's object store, not from your machine.sb deploysends the manifest above with every module, declarations first.
The name you declare is namespaced. You declare countdown; the page
carries app.<your appId>.countdown. The page has one registry, so two apps
both shipping a Countdown would silently replace each other — namespacing
makes that impossible. Consequences: the declared name must not contain
. (the separator), a space or #; and register the namespaced name,
not the bare one — the app id is on your app.
We host your module; you do not link to it. The bytes are uploaded with the version and served by the platform. The storage key is the SHA-256 of the bytes: uploading the same content twice yields the same key, two versions shipping the same module share one object, and an approved version's module can never change — a later upload to a draft does not touch it. Hosted rather than fetched from your URL, because a URL is only a pointer: a reviewer would be approving an address whose content you could change the next day.
Limits: 12 islands per version, 512 KiB per module.
What we can check. The size, and that the bytes are text. We cannot check that your module is JavaScript — JavaScript has no file header. What governs what your code does is review, and only review. Review gives your users a stable, auditable artifact and a publisher identity; it does not prove behaviour — an island calling your own API at runtime is normal. Merchants are told, on the marketplace card before they install, that your app runs code on their store.
7. App-owned data: putting your content into the HTML
An island runs after the document has arrived, so nothing it draws is in the HTML a search engine reads. If what your app sells is content — reviews, ratings, specs, badges — an island alone leaves it invisible to search.
App-owned data fills that gap. You store values on the merchant's store; their page binds to them; the server prints them straight into the published HTML. No island, no markup from you.
Store a value
PUT /api/v1/app-data
Authorization: Bearer <your app's wba_ token>
{ "key": "rating", "value": "4.6 out of 5" }
There is no app id in that body, and no field for one. The namespace comes from your token, so you cannot write into another app's — the same rule as your island names.
PUT with no id in the path: a value is named by its own key, so setting it
twice is setting it once. You never read before you write, and a retry after a
timeout cannot create a duplicate.
You need the appdata.write scope at install, and appdata.read to read
your own values back with GET /api/v1/app-data. Delete one with
DELETE /api/v1/app-data/{key}.
Two kinds of owner
| owner | you send | the value is about |
|---|---|---|
site (default) |
nothing, or "owner":"site" |
the whole store |
product |
"owner":"product","ownerId":"<product id>" |
that one product |
PUT /api/v1/app-data
{ "owner": "product", "ownerId": "prd_1a2b", "key": "rating", "value": "4.9" }
Choose product for anything that varies per product — most of what makes
this worth doing. A site-level rating prints the same number on every
product page, which is worse than none. A product value without an ownerId
is refused, rather than silently stored at site level where nobody finds it.
Limits: 500 values per app per store (409 too_many_keys), 8 KiB per
value. A value ends up inside the HTML a shopper downloads, so its weight is
the merchant's page weight.
Read it from a block
Declare a binding on your block's node, with source app.<key>:
{
"id": "rating-text",
"type": "heading",
"props": {
"specials": { "text": "No rating yet" },
"bindings": [{ "source": "app.rating", "field": "specials.text" }]
}
}
You write app.rating, never app.<your appId>.rating. The platform fills
in the app id when it materialises your block, and that is what makes another
app's namespace unreachable. A site value resolves anywhere on the page; a
product value resolves on a node bound to that product.
When your app goes away
Uninstalling stops resolving your data immediately and deletes nothing. Every element bound to one of your values falls back to the text it was authored with — not to a blank — so a page never breaks because an app left. Reinstall and everything returns. Your data is kept indefinitely; the merchant can purge it deliberately if they want it gone. Disabling the app is the same: a disabled install resolves nothing, re-enabling brings it back.
The limit: merchants cannot bind your data onto their own elements. This works for a binding a BLOCK declares — in your manifest. Do not build a product on merchants wiring your values by hand; put the binding in the block.
8. When something is missing
Your block is server-rendered HTML first. An island only adds behaviour to markup that has already rendered, so:
- the shopper has JavaScript off, or the module fails to load — the block still renders as it should, minus the behaviour. One line in the console.
- your island is declared but no module is uploaded — you cannot submit.
- the merchant uninstalls your app — every block of yours leaves their pages on the next read. They are warned first, with the pages and blocks affected.
- your app is suspended, or your version withdrawn before they were on it — the same as uninstall, block-wise; withdrawal alone does not touch stores already running that version.
Design for the first case: put your content in the markup and use the island to enhance it, not to fill an empty shell.
9. The refusals you will actually meet
Every refusal is JSON with a machine code and a message saying what is wrong.
Manifest (PUT …/blocks)
| code | status | meaning |
|---|---|---|
unknown_element_type |
400 | a nodes[].type the platform does not render |
unknown_trait_widget |
400 | a widget the editor does not have |
unknown_write_key |
400 | a writeKey not valid for that target |
invalid_block |
400 | a shape rule: bad key, missing name, no rootId |
invalid_block_subtree |
400 | the nodes do not form one tree from rootId |
unknown_island |
409 | a node names an island the version does not declare |
duplicate_block_key |
409 | two blocks share a key |
too_many_blocks |
409 | more than 24 |
not_draft |
409 | the version has left draft — create a new one |
body_too_large |
413 | more than 1 MiB |
block_vocabulary_unavailable |
503 | the server cannot check names right now; nothing was stored |
Islands (PUT …/islands, POST …/islands/module)
| code | status | meaning |
|---|---|---|
invalid_island_name |
400 | not a lower-case slug, or contains ., a space or # |
island_name_required |
400 | the upload did not say which island |
island_module_not_text |
400 | empty, or not valid UTF-8 |
duplicate_island_name |
409 | two islands share a name |
too_many_islands |
409 | more than 12 |
island_not_found |
404 | an upload for a name the version does not declare |
island_module_too_large |
413 | more than 512 KiB |
image_storage_unavailable |
503 | this deployment cannot store uploads |
Submit (POST …/submit) re-runs every check above on what is saved,
because a widget can be renamed between the day you saved and the day you
submit. It adds one code of its own:
| code | status | meaning |
|---|---|---|
island_no_module |
409 | a declared island with nothing uploaded |
10. Known limits
- Islands do not run on the editor canvas. The marker is emitted only at publish, so the merchant sees your markup in the editor and your behaviour only on the storefront. That is the render contract of §1 working as intended.
- Whether your module hydrates on a published page is yours to test. The platform tests every layer around it — manifest, upload, marker, address — but runs no line of a third-party island. Try it on a real published page before you submit.
- A block
keyhas no versioning. Renaming one in a later version does not migrate the merchant's page — it removes the block from it. Choose keys you can live with. - A block cannot contain a block. A manifest node claiming to be a block reference has that claim stripped.
Updated 07/10/2026