Metaobjects — merchant-defined content types

Merchant-defined structured content (size charts, brand profiles, FAQ blocks) served by TYPE + HANDLE (metaobjects card). ACTIVE entries only — drafts 404 (lib filter AND anon RLS, defense in depth).

Capabilities per definition:

  • renderable → payload gains seo {title, description} (title falls back to displayName, description to null);
  • online_store → payload gains url (/metaobjects/<type>/<handle> — the storefront prepends its origin).

References resolve at read time: a target the storefront cannot see (deleted, or draft under anon RLS) yields reference: null — never dropped, never a 500. by-metafield is a reserved type slug (the static route shadows /:type; rejected at definition create).

Metaobject shape (all endpoints):

{
  "id": "uuid",
  "type": "size-chart",
  "handle": "shirts-eu",
  "displayName": "Shirts (EU)",
  "fields": [
    { "key": "body", "kind": "rich_text", "value": "<table>…</table>" },
    { "key": "product", "kind": "product_reference", "value": "prod_…",
      "reference": { "id": "prod_…", "title": "Linen Shirt", "handle": "linen-shirt" } }
  ],
  "seo": { "title": "Shirts (EU)", "description": null },  // renderable only
  "url": "/metaobjects/size-chart/shirts-eu",              // online_store only
  "updatedAt": "ISO-8601"
}

Definitions + entries are admin-authored; the shared dev tenant seeds none, so the executable blocks prove the 404/400 contracts and the happy paths are pinned by tests/store/metaobjects-store.test.ts (admin-auth fixtures).

GET /api/store/metaobjects/:type — list entries

  • Purpose: list ACTIVE entries of one type (e.g. all FAQ blocks), newest-updated first.
  • Auth: anon (x-client-id).
  • Request: query {limit?, offset?}limit clamped to 1–100 (default 20; out-of-range values are clamped, not rejected).
  • Response: {metaobjects: [Metaobject…], count, offset, limit} — list payloads carry raw field values (reference NOT resolved; the by-handle read is the resolve hot path). seo/url capabilities still apply.
  • Errors: 404 not_found (unknown type).
  • SDK: metaobjects.listMetaobjects(client, type, query?)
  • Components: FAQ/content-block section renderers.
  • Settings: definitions + entry status in admin → Content → Metaobjects.
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
  "$BASE/api/store/metaobjects/doc-no-such-type-$RUN" -H "x-client-id: $CLIENT_ID")
test "$STATUS" = 404
curl -s "$BASE/api/store/metaobjects/doc-no-such-type-$RUN" -H "x-client-id: $CLIENT_ID" \
  | grep -q '"code":"not_found"'

GET /api/store/metaobjects/:type/:handle — one entry

  • Purpose: render one entry — references RESOLVED (invisible targets → reference: null).
  • Auth: anon (x-client-id).
  • Response: {metaobject: Metaobject} (shape above, references resolved).
  • Errors: 404 not_found — unknown type, unknown handle, or a DRAFT entry (drafts are indistinguishable from missing, by design).
  • SDK: metaobjects.getMetaobject(client, type, handle)
  • Components: size-chart modal, brand-profile block.
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
  "$BASE/api/store/metaobjects/doc-no-such-type-$RUN/doc-h-$RUN" \
  -H "x-client-id: $CLIENT_ID")
test "$STATUS" = 404

GET /api/store/metaobjects/by-metafield — the product→chart chain

  • Purpose: the canonical fetch chain — resolve an entity's metaobject_reference METAFIELD into full metaobject payload(s) in ONE call: storefront reads the product (id in hand) → this call with the metafield key → the resolved chart.
  • Auth: anon (x-client-id). Leak boundary (deliberate): only metaobject_reference definitions resolve here — any other metafield key 404s, so no other metafield value can exit through this route; entries return ACTIVE-only.
  • Request: query {entity_type, entity_id, key} — all three required.
  • Response: metaobjects always present, in stored order. metaobject (first entry or null) is present only for single-valued definitions — LIST definitions return {metaobjects} alone:
// single-valued definition
{ "metaobject": { /* Metaobject */ }, "metaobjects": [ /* [it] */ ] }
// list definition
{ "metaobjects": [ /* Metaobject[] in stored order */ ] }
  • Errors: 400 invalid_data (missing entity_type/entity_id/key) · 404 not_found (no such definition, wrong field_type, or no stored value for the entity).
  • SDK: metaobjects.getMetaobjectsByMetafield(client, query)
  • Components: PDP size-chart trigger; any metafield-driven block.
  • Settings: the metafield definition (entity type + key + metaobject_reference field type) in admin → Settings → Metafields.
# Missing params → 400 invalid_data.
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
  "$BASE/api/store/metaobjects/by-metafield?entity_type=product" \
  -H "x-client-id: $CLIENT_ID")
test "$STATUS" = 400
# Unknown key on a real seeded product → 404 (the leak-boundary contract).
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
  "$BASE/api/store/metaobjects/by-metafield?entity_type=product&entity_id=prod_01tst00000000000000000001&key=doc-nokey-$RUN" \
  -H "x-client-id: $CLIENT_ID")
test "$STATUS" = 404