Five metafield and metaobject traps that fail silently
App-owned metaobjects are the right place to keep an app's structured data — it lives in the merchant's store, renders in Liquid with no storefront JavaScript, and survives your app being uninstalled. Getting there means walking past five failure modes that produce no error at all.
1. Entries and definitions are two different scopes
You will almost certainly request read_metaobjects / write_metaobjects and consider the matter closed. Then metaobjectDefinitionByType returns an access error, because definitions are governed separately:
| Scope | Governs |
|---|---|
read_metaobjects / write_metaobjects | The entries — the actual records |
read_metaobject_definitions / write_metaobject_definitions | The schema — the types and their fields |
And the TOML block is not a grant
Declaring [[metaobjects.app.*]] in shopify.app.toml creates the app-owned definitions. It does not give your app runtime API access to them. The access_scopes line is a separate statement and you need both.
2. A reference metafield needs its definition to exist first
Writing a metaobject_reference (or list.metaobject_reference) value via metafieldsSet before the corresponding metafield definition exists fails with "Value requires that you have a metafield definition". Simple-typed metafields are forgiving here; reference types are not.
So the install/setup order is fixed: metaobject definition → metafield definition → then values. Make that sequence part of your app's setup routine rather than something that happens to have run once on your dev store.
3. Product search cannot filter your own metafield namespace
The worst one, because it looks like it worked
A query like products(first: 50, query: "metafields.app--12345--sizecast.chart:*") returns zero rows and no error. Not an access error, not an invalid-query error — an empty, entirely well-formed result. Every "nothing appears in the list" bug of this shape traces back here.
App-owned (reserved) namespaces aren't part of the product search index. The workaround is unglamorous and reliable — page through products and filter on the metafield you read back per node:
products(first: 250, after: $cursor) {
pageInfo { hasNextPage endCursor }
nodes {
id title
metafield(namespace: $ns, key: $key) { value }
}
}
Then keep the nodes where metafield is non-null. Cache the resulting assignment list in your own database so you're not scanning the catalogue on every page load, and refresh it when you write.
4. Deleting a metaobject cascades — asynchronously
Deleting a metaobject entry also clears the metafields that reference it. That part is correct and desirable. The part that bites is that the cascade runs after the delete mutation returns: a write that races the cleanup job can be silently removed, so a value you just set comes back empty a moment later.
If you delete and re-assign in one flow, verify with a delayed read-back rather than trusting the mutation's success payload.
5. Adding a scope doesn't get you a token that has it
This one is specific to token-exchange apps, and it is the reason a scope fix can appear not to work at all. The session-validation path checks whether the token is active — it does not compare the token's scopes against what the app now requests. So after a merchant approves your new scopes, the app can keep using the old, narrower offline token indefinitely.
The practical fix is to treat an access-denied error as a signal that the token is stale, not that the merchant declined:
try {
return await run(admin);
} catch (e) {
if (!/access denied|not approved/i.test(String(e))) throw e;
await deleteSessionsForShop(shop); // drop the stale offline token
const { admin: fresh } = await authenticate.admin(request); // re-exchange
return await run(fresh); // retry exactly once
}
Related, same family
Offline tokens now carry an expiry of roughly a day and are refreshed on admin requests. An out-of-band script that reads the stored token straight from your database will start getting "Invalid API key or access token" once it lapses — so run API verifications right after the merchant has opened the app, not hours later.
Why app-owned metaobjects are still worth it
Every trap above is a one-time cost. What you get in exchange is data that lives in the merchant's store rather than in your database, readable directly from Liquid — which means your storefront surface can be zero JavaScript, which is what keeps a theme app extension inside the Built for Shopify performance budget without any optimisation work later.
Related: fixing "Translation missing" in an app block, and Managed Pricing plan detection.
Built on exactly this architecture
We keep app data in app-owned metaobjects and render it in pure Liquid — no storefront scripts, and localisation that survives Arabic RTL.
Book a call →