Store Builder

Build a block

Ship a block your app owns, so a merchant can drag it onto a page in the editor and it keeps rendering from your app after they do.

A block is something your app contributes to the merchant's page editor: a prebuilt piece of a page, plus the small set of controls the merchant is allowed to change on it. They drop it from the palette like any other element; it renders on their storefront; and when you ship a fix, every store that installed you gets it.

This page assumes you already have an app. If you do not, start there — a block belongs to an app version, and versions are what review approves.


1. What a block is, and what it is not

A block is a subtree of this platform's own elements, plus a declared trait bar.

It is not markup. You do not write HTML, CSS or a template. You describe a tree of nodes — flex-block, heading, button, image — with the same properties the merchant's own elements have, and this platform renders it.

That restriction is not a stylistic preference and it is worth one honest paragraph, because it will shape everything you build here. This platform renders every page twice: once in the browser, in the editor's canvas, and once in Go, when the page is published. Those two renderers must produce byte-identical HTML — that is the invariant the whole product rests on, and it is enforced by a test that renders a document through both and compares. Markup that only one of them knows how to draw would break it. So a block is expressed in the vocabulary both renderers already share, and the price you pay is that you cannot invent an element. The thing you get back is that your block behaves exactly like a native one: it responds to the merchant's breakpoints, their theme, their fonts, and it keeps working when any of those change.

If your block needs to do something — a countdown, a carousel, a fetch — that is an island, and it is section 6.


2. The manifest

One PUT replaces your version's whole palette contribution. The set is what a reviewer approves, so there is no per-block route: send all of them, every time.

PUT /api/orgs/{orgId}/apps/{appId}/versions/{versionId}/blocks
Authorization: Bearer <your user token>
Content-Type: application/json

A complete manifest that would validate:

{
  "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 by field:

field what it is
key your id for this row, lowercase slug. Half of the reference a merchant's page stores, so it must not change once shipped.
name what the merchant reads in the palette.
icon palette glyph. Unresolvable names fall back to a default — this one is not held against a vocabulary.
rootId which node the drop places. Stated explicitly, never inferred.
nodes[] the subtree. id is yours and local to the block; the ids on the merchant's page are minted at drop.
nodes[].type an element type this platform renders. Checked.
nodes[].props the node's own style / config / specials, carried through verbatim.
slots[] names an inner node so a trait can address it.
traits[] the merchant's controls — section 3.

Caps: 24 blocks per version, 1 MiB for the whole document.

Where the vocabularies come from

type and widget are checked against what the editor actually ships, not against a list in this document — the check reads a generated vocabulary that moves with the editor. Two consequences worth planning around:

  • a name that is right today can be renamed or deleted by a later release of the editor. Your saved draft is re-checked at submit for exactly this reason.
  • there is no stable published list of element types in this guide, because a list here would be wrong within a release. The error tells you what you got wrong, by name.

3. The trait bar

The trait bar is the merchant's entire editing surface on your block. What you declare is what 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 addresses the block's own root node.
  • target — one of style, config, specials.
  • writeKey — the key that widget writes.

A declaration opens a NAMESPACE, not a key

This is the part that surprises people, so it is stated plainly: declaring { target: "style", writeKey: "color" } on a slot opens that node's whole style namespace to the merchant, not the single key color.

That is deliberate. Most widgets write more than one key — a border control writes four sides, a shadow control writes offset, blur and colour — and per-key permission would make those widgets half-work in a way that looks like a bug rather than a rule. So permission is per namespace, and writeKey remains required because it documents what the control is for and is held against the platform's write-key vocabulary.

What this means for you: if you would not be 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 the one you meant.

The three namespaces

target holds
style per-breakpoint CSS — colours, sizes, spacing
config per-breakpoint data that is not CSS
specials base-only content and identity — text, a chosen tag

4. What a merchant can and cannot change

A dropped block is sealed. The merchant cannot select, edit, move, duplicate or delete anything inside it, and they cannot paste into it. The trait bar you declared is the whole surface.

What they can do is delete the block itself, and change whatever your bar exposes.

Their settings survive your updates. A merchant's changes are stored on the reference — the single node their page keeps — keyed by slot, and re-applied every time the block is materialized. So when you ship a new version, their colour choice is still their colour choice.

Two consequences of that design that you should know:

  • a setting for a control you remove in a later version goes inert. It is not deleted and not re-applied; if you add the control back, it comes back with it.
  • their page stores a reference to your block, not a copy of it. There is no forked copy of your markup anywhere. That is what makes "ship a fix and every client site is fixed" true — and it is why the manifest is reviewed.

5. Versions

Approval attaches to a version, never to your app, because approval pins a payload: if it attached to the app you could change what a reviewer had approved.

Once a version is approved:

  • a same-scopes approved version moves every install automatically. No merchant action, no fresh consent — because the scopes did not change. This is the point of the whole design: you fix one thing, and every store that installed you gets the fix.
  • a version asking for MORE than a merchant granted stops and asks. It waits for their consent, on their consent screen.
  • a merchant can roll back to any earlier approved version, and is told before they choose which of your blocks that version does not contribute — so a rollback that would remove a section from their live pages says so first.

If you 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 can land on the version, and every store already running it keeps running it. Pulling a live block out from under a merchant's page would be a worse cure than the disease. To move them off it, ship a fixed version — they will be moved automatically if the scopes match.

When your update reaches the storefront

Immediately in the merchant's editor. On their published pages, at the next publish of that page — the platform re-renders any live page still serving an older build of your block when the merchant next publishes anything. It does not republish their site on your behalf.


6. Islands: making a block do something

An island is a JavaScript module your app ships. A node in your manifest names one, and on the published page that node hydrates with your code.

Three steps.

Declare the name. Names only — the code arrives separately.

PUT /api/orgs/{orgId}/apps/{appId}/versions/{versionId}/islands

{ "islands": [{ "name": "countdown" }] }

Point a node at it, with the island field on that node 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 rather than a file picker.

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 re-sends it each time you save — it is the same POST, and the page still has to be refreshed afterwards, because an island's code is served from the platform's object store rather than from your machine. sb deploy sends the manifest above and every module together, declarations first.

The platform namespaces your island, and hosts it

Two things follow, and both will bite you if you skip them.

Your declared name is namespaced. You declare countdown; the page carries app.<yourAppId>.countdown. There is one registration table for the whole page, so two apps both shipping Countdown would silently replace each other — one agency's blocks would start running another's code. Namespacing makes that impossible rather than merely unlikely. Consequences:

  • your declared name may not contain a . — that is the separator, and allowing it would let a manifest impersonate another publisher. It also may not contain a space or #, which are the list and method separators the page uses.
  • register the namespaced name, not the bare one. Your app id is on your app; the marker on the published element carries the exact string.

We host your module, you do not link to it. The bytes are uploaded with the version and served by this platform. The stored key is the SHA-256 of the bytes, which has one consequence you will notice: uploading identical bytes twice gives you the same key and stores nothing new, and two versions shipping the same module share one object. It also means an approved version's module can never be changed — a later upload to a draft cannot touch it.

We host it rather than fetching a URL you supply because a URL is a pointer: review would be approving an address whose contents you could change the next day. What review buys is a stable artifact and a publisher who cannot quietly change what shipped.

Caps: 12 islands per version, 512 KiB per module.

What we can and cannot check

We check the size, and that the bytes are text. We cannot check that your module is JavaScript, because JavaScript has no header to recognise — any text might be a module. The control on what your code does is the review, and it is the only one there is.

Be clear-eyed about what that means for you as well: review gives your users a stable, auditable artifact and a publisher identity. It does not, and cannot, prove behaviour — an island can call your own API at runtime, which is normal and expected. A merchant is told, on the marketplace card before they install, that your app runs code on their store.


7. App-owned data: getting your content into the HTML

An island runs after the document arrives, 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 closes that. You store values on the merchant's store; their pages BIND them; the server prints them into the published HTML. No island involved, and no markup from you.

Storing 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 there is 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, one door over.

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 writing, 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}.

The two owners, and how you address one

A value is about the STORE or about one PRODUCT, and which you choose decides where it can be read.

owner you send the value is about
site (the default) nothing, or "owner":"site" the store as a whole
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, which is most of what makes this worth doing. A rating is the canonical case: a site-level rating would print the same number on every product page, which is worse than printing nothing. A product value with no ownerId is refused rather than quietly stored as a site value where nothing would ever find it.

Caps: 500 values per app per store, 8 KiB per value. A value ends up inside the HTML of a page a shopper downloads, so its weight is the merchant's page weight.

Reading it from a block

Declare a binding on your block's node, with the 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.<yourAppId>.rating. The platform supplies the app id when it materializes your block, which is what makes another app's namespace unreachable — you cannot name it, so there is nothing to enforce and nothing you can get wrong.

A site-owned value resolves anywhere on the page. A product-owned value resolves on a node bound to that product, which is what the binding's target names.

What a merchant sees when your app goes

Uninstalling your app stops your data resolving immediately and deletes nothing. Every element bound to one of your values falls back to the text it was authored with — not to an empty gap — so a page never breaks because an app left. Reinstalling brings everything back with no action from anyone.

Your data is retained indefinitely, and the merchant can purge it explicitly if they want it gone. That is their decision, not a timer's.

The same applies while your app is merely switched off: a disabled install resolves nothing, and switching it back on restores everything.

The limit this does NOT cover

A merchant cannot bind your data on their own elements. This works for a binding a BLOCK declares — one you wrote, in your manifest. A merchant pointing their own heading at app.<you>.rating would have to name your app explicitly, which needs a picker in the editor and a read-time check that do not exist yet. Do not build a product around merchants wiring your values by hand; put the binding in your block.


8. When things are missing

Your block is server-rendered HTML first. An island only ever adds behaviour to markup that already rendered, so:

  • shopper has JavaScript disabled, or the module fails to load — the block renders exactly as it would have, minus the behaviour. Nothing collapses, no empty box. One line in the browser console.
  • your island is declared but has no uploaded module — you cannot submit. A name with nothing behind it would be a marker for a module nothing serves, and the page would silently do nothing.
  • the merchant uninstalls your app — every one of your blocks disappears from their pages at the next read. They are warned first, and told which pages and which blocks it would affect.
  • your app is suspended, or your version is withdrawn and they were never on it — same as an uninstall for the blocks; withdrawal alone does not affect stores already running the version.

Design for the first case deliberately: put your content in the markup and use the island to enhance it, not to fill an empty shell.


9. Refusals you will actually hit

Every refusal is JSON with a machine code and a message naming what was wrong.

Manifest (PUT …/blocks)

code status means
unknown_element_type 400 a nodes[].type this 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 past 24
not_draft 409 the version has left draft — create a new one
body_too_large 413 past 1 MiB
block_vocabulary_unavailable 503 the server cannot check names right now; nothing was stored

Islands (PUT …/islands, POST …/islands/module)

code status means
invalid_island_name 400 not a lowercase 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 past 12
island_not_found 404 uploading for a name the version does not declare
island_module_too_large 413 past 512 KiB
image_storage_unavailable 503 this deployment cannot store uploads

Submit (POST …/submit) re-runs every check above against what is stored, because a widget can be renamed between the day you saved and the day you submit. It adds one of its own:

code status means
island_no_module 409 a declared island with nothing uploaded

10. Known limits, stated rather than smoothed

  • No test in this platform's own suite executes an app module. Every layer around it is tested — the manifest, the upload, the marker, the address — but nothing here runs a line of a third-party island. Your module hydrating on a published page is exercised by your testing, not by ours. Treat that as a reason to test on a real published page before you submit.
  • Islands do not run in the editor canvas. Markers are emitted only on publish, so a merchant sees your markup in the editor and your behaviour only on the storefront. That is the render contract from section 1 working as intended, not a bug.
  • There is no versioning of the block key. A key is half of the reference a merchant's page stores, so renaming one in a later version does not migrate their pages — it removes the block from them. Choose keys you can live with.
  • A block cannot contain another block. A manifest node claiming to be a block reference has that claim stripped.

Updated 22/08/2026