Store Builder

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 in slots[]. The reserved slot $root is the block's root node.
  • target — one of style, 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 dev watches the file and resends on every save — the same POST, and you still refresh the page, because an island's code is served from the platform's object store, not from your machine. sb deploy sends 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 key has 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