METAOBJECTS · CONTENT · TECHNICAL · 16 SEPTEMBER 2025 · 7 MIN READ
Metaobjects at scale: when content modelling becomes a system
Metaobjects are a database you design once and query for years. The query surface is narrower than the modelling surface, and that is the thing to design around.
Metaobjects work for large content models as long as you design for how they can be read, not for how the content looks on a whiteboard. A metaobject definition is a schema — typed fields, capabilities, and separate admin and Storefront API access — and entries are addressed by type and handle. The constraint that decides your model is the query surface: the metaobjects query requires a type, so there is no cross-type query; in the Admin API you can filter on fields.{key} only for fields declared filterable in the definition; and in the Storefront API there is no field filtering at all, with sorting limited to id and updated_at. Model the access path first and the content shape second, because fields are cheap to add and a filter you did not declare is a migration.
IN SHORT
- A metaobject definition is a schema, not a folder: typed fields, capabilities, and access set separately for the Admin and Storefront APIs.
- Every metaobject query is scoped to one type. There is no query that spans types, so a model split across five types needs five requests.
- Admin API filtering works on `fields.{key}` only where the definition marks that field filterable — which means filters are a schema decision made before the content exists.
- The Storefront API does not filter metaobjects by field at all, and sorts only by `id` or `updated_at`. Anything resembling search has to be served from somewhere else.
- The four capabilities — publishable, translatable, renderable and onlineStore — change behaviour, not just metadata: with publishable on, a draft entry returns nil in Liquid.
- `metaobjectUpsert` keys on type plus handle, which makes seeding idempotent — but its `values` input clears any key you omit, while its `metaobject` input patches.
The shift that makes it a system
One metaobject type holding eight size charts is a convenience. Forty types holding store locations, ingredients, care instructions, authors, awards, shipping zones and landing-page modules is a content database inside Shopify, and it behaves like one: the schema is the expensive artefact, the entries are cheap, and the mistakes are structural rather than editorial.
The thing teams get wrong is treating a definition like a content type in a CMS they have used before. In a headless CMS you are usually free to query across types, filter on any field, and sort by whatever you stored. Metaobjects do not work that way, and the difference is not a gap to be worked around later — it is the first input to the model.
The query surface, precisely
Worth being exact here, because almost every metaobject design regret traces back to one of these four facts.
`type` is required. Both the Admin and Storefront metaobjects queries take a mandatory type argument. There is no "give me everything tagged X across types". If your model needs that, you are building an index somewhere else.
Admin filtering is opt-in per field. The Admin query accepts a query argument with a documented search syntax covering display_name, handle, id and updated_at, plus fields.{key} — and the docs are specific that fields.{key} works for fields marked as filterable in the definition. Wildcards and range comparisons are supported on those. A field nobody marked filterable is not filterable.
Admin sorting is a fixed list. sortKey accepts id, type, updated_at or display_name. There is no sorting by an arbitrary field, so an ordering your merchandisers care about — priority, date of an event, alphabetical by something that is not the display name — has to come from display_name being the thing you sort by, or from ordering held explicitly in a list reference.
The Storefront API is narrower still. Its metaobjects query takes type, pagination, reverse and a sortKey of id or updated_at. No field filtering. If a storefront page needs "all locations within this county", the Storefront API will not answer it from metaobjects — you fetch the set and filter client-side, pre-compute the set at build time, or serve it from your own back end.
- Design the filters into the definition before the content exists, because adding one later means editing the definition and re-verifying every entry against it.
- Prefer fewer, wider types over many narrow ones where the reads always happen together — each extra type is another round trip.
- Where a set has a meaningful order, store the order: a list reference field on a parent entry is an ordering you control, and
updated_atis not. - If a page needs search or facets over metaobjects, decide now whether that index lives in a headless front end, a custom app, or a search service. It does not live in the Storefront API.
Model the read path, then the content
The practical method is to write down every page and component that will consume the content, and next to each one write the exact query it will issue. Not the data it needs — the query. If the query cannot be expressed with a mandatory type, a declared filterable field and a sort key from the fixed list, the model is wrong and it is cheaper to know now.
Two patterns come out of that exercise repeatedly.
Address by handle wherever the content is singular. Liquid reaches a metaobject as metaobjects.type.handle, and the Storefront API can fetch one by handle. A size chart, a shipping policy, a landing-page hero: give it a deliberate, stable handle and the read is free. Handles are the most underused feature of the whole system, because they turn a lookup into a constant.
Hold sets as references on a parent. Rather than expecting to query "the six ingredients for this product", put a list of metaobject references on the product and let the graph do the work. The ordering is explicit, the read is one hop from something you were already fetching, and you never need a filter.
Capabilities are behaviour, and they are hard to walk back
A definition carries four documented capabilities and each one changes what the entries do.
publishable gives entries a status. The consequence to know is in Liquid: with publishable enabled, only entries with active status are accessible, and a draft returns nil. That is exactly what you want for content someone is drafting, and it is a silent empty section if a template does not handle nil.
translatable makes the fields available to translation, which for a multi-market store is the difference between a content model that localises and one that gets duplicated per language. Decide it when you create the type, not when you open a second market.
renderable makes the definition support rendering and exposes SEO-related data, and onlineStore makes it eligible to display as a page on the online store — with a metaobject template in the theme, where the current entry is available as metaobject. Together those turn a type into a set of real URLs, which is the right answer for store locations or ingredient glossary pages and the wrong answer for reusable page fragments, which should have no URL of their own.
Access is also part of the schema and set per API: a type can be readable by the Storefront API or not, and app-owned types default to no Storefront access at all. A field that renders fine in the admin preview and is empty on the storefront is almost always this.
Getting the content in, and keeping it correct
At a few dozen entries the admin is fine. At a few thousand — a location per store, a spec sheet per SKU — you are writing an import, and there are three things worth knowing before you do.
metaobjectUpsert creates or updates by type plus handle, which makes an import idempotent: run it twice and you have one entry, not two. That property is worth designing the handles around, because it turns a migration into something you can re-run after fixing a mapping bug.
Its two input shapes are not interchangeable. The metaobject input patches — fields you do not mention keep their values. The values input replaces — keys you omit are cleared. They cannot be used together. A sync written with values and a partial payload quietly empties every field the sender did not know about, which is the kind of bug that is discovered by a merchandiser rather than by a test.
On volume, work within the documented API limits rather than guessing: array arguments take a maximum of 250 items, and paginated queries are capped at 25,000 objects. Bulk operations are the documented route for large reads. For large writes the docs are less clear-cut — the bulk import guide says any Admin mutation may be supplied except the two bulk mutations themselves, while the argument’s own documented valid values list only the product and collection mutations. We would not build a delivery plan on metaobject bulk imports without testing it on the version you are running; a paced loop against the rate limit is unglamorous and predictable.
- Derive handles from a stable upstream identifier, never from a title someone may edit.
- Use the patching input for syncs and the replacing input only for a full authoritative load, and never let one code path do both.
- Validate against the definition before sending, because a type mismatch fails per entry and a half-applied import is worse than a rejected one.
- Record what you wrote.
metaobjectsCounton the definition tells you how many entries exist, which is the cheapest reconciliation check there is.
When to stop, and put the content somewhere else
We would rather you used metaobjects than a CMS — they are already there, they cost nothing extra, they are in the admin your team uses, and they are reachable from Liquid, the Storefront API and the Admin API without an integration. That covers most content models comfortably.
Three signals say the model has outgrown them. Editorial workflow that needs more than draft and active — review, approval, scheduled publishing, versions, a way back to last week’s copy. Content assembled from systems that are not Shopify, where Shopify would be storing a copy rather than the record. And reads that the query surface cannot express: search, facets, sorting by a field that is not a sort key, or a query that has to span types.
Those three are why the honest answer is sometimes a headless CMS alongside Shopify, or a custom app with its own store and its own indexes reading Shopify as the commerce layer. Both cost more than metaobjects — a system to run, a sync to keep honest, another place a page can break — so make the decision on a read you genuinely cannot serve, not on a diagram that looked tidier.
The version of this that ages worst is the halfway house: a model that mostly lives in metaobjects with the difficult third of it in a spreadsheet nobody automated. Pick one home per type, write down which reads it has to serve, and revisit it when a read arrives that it cannot.
Questions this raises
How do metaobjects work for large content models?
Well, provided the model matches the query surface. A definition gives you typed fields, capabilities and per-API access, and entries are addressed by type and handle. The limits are in reading: every query is scoped to one type, Admin filtering only works on fields the definition declares filterable, and the Storefront API cannot filter by field at all. Design the queries each page will issue before you design the fields.
Can you query metaobjects across multiple types?
No. Both the Admin and Storefront `metaobjects` queries take a required `type` argument, so a model spread across five types needs five requests and any cross-type view has to be assembled by the caller. Where reads always happen together, that is a reason to prefer one wider type over several narrow ones.
Can you filter metaobjects by a field value?
In the Admin API, yes — but only on fields the definition marks filterable, using the documented `fields.{key}` search syntax with wildcard and range support. In the Storefront API there is no field filtering; sorting is limited to `id` and `updated_at`. Storefront filtering therefore happens in your front end, at build time, or in your own service.
Should metaobjects replace a headless CMS?
Often, yes. If the content is commerce-adjacent, edited by the same people who use the Shopify admin, and read by predictable queries, a second system is cost without benefit. Reach for a CMS when you need real editorial workflow — approvals, scheduling, versions — when content comes from systems other than Shopify, or when the reads need search and faceting.
What breaks when you enable the publishable capability?
Status starts mattering. Shopify documents that with publishable enabled, only entries with `active` status are accessible in Liquid and draft entries return nil. That is the point of the capability, but any template that assumed an entry would always be there needs to handle the empty case, or a section silently disappears when someone moves an entry back to draft.
How do you import thousands of metaobject entries?
Use `metaobjectUpsert` keyed on type plus handle so the import is idempotent and re-runnable, derive handles from a stable upstream id, and pace the writes against the rate limit. Keep in mind the documented API limits — 250 items per array argument, 25,000 objects per paginated query — and test any bulk-import route on your API version rather than assuming it is supported.
NEXT STEP
Free store audit
A senior Shopify engineer reviews your storefront, theme performance and checkout, then sends a prioritised list of fixes.
