Shopify metaobject pages are entries of a metaobject definition that render as real storefront URLs. Enable the onlineStore capability on the definition (and renderable, for SEO fields), then add templates/metaobject/{type}.json to the theme. Every entry of that type is then served at /pages/{urlHandle}/{entry-handle}, with the entry exposed to Liquid as metaobject.
That is the whole mechanism. What follows is the order to do it in, the Admin GraphQL inputs that are easy to get wrong, and the three things to check when a merchant gets a blank page instead of a profile.
The example is a fashion store that wants a page per designer. The queries were written against Admin GraphQL 2026-07.
Which capabilities does the definition need?
Two, and the page only works with both.
onlineStore is the one that creates the URL. Its data takes a urlHandle, which becomes the path segment between /pages/ and the entry handle, so urlHandle: "designers" plus an entry with handle ines-moreau gives /pages/designers/ines-moreau. It also takes createRedirects, which defaults to false; set it to true and Shopify writes a redirect when a published entry’s handle changes, which is the behaviour you want on anything that is going to be linked from outside the store.
renderable is the one that gives the page a title tag and a meta description. Its data maps two of your own field keys, metaTitleKey and metaDescriptionKey, onto the SEO fields. Shopify’s capabilities guide says renderable also puts entries in sitemap.xml. Without it the page still renders, but with no SEO fields for the merchant to edit, which is a strange thing to ship.
You will almost certainly want publishable as well, so a half-written profile can sit in DRAFT without being served. It is also the capability behind the most common blank page, covered below.
One constraint before you reach for shopify.app.toml: as of October 2026 the capabilities guide states that onlineStore is not supported in TOML configuration. If you manage metaobject definitions as TOML the way our metafield definitions in TOML post describes, this capability has to be switched on by mutation or in the admin.
mutation CreateDesignerDefinition {
metaobjectDefinitionCreate(
definition: {
type: "designer"
name: "Designer"
access: { storefront: PUBLIC_READ }
fieldDefinitions: [
{ key: "name", name: "Name", type: "single_line_text_field", required: true }
{ key: "summary", name: "Summary", type: "single_line_text_field" }
{ key: "bio", name: "Biography", type: "rich_text_field" }
{ key: "portrait", name: "Portrait", type: "file_reference" }
{ key: "products", name: "Products", type: "list.product_reference" }
]
capabilities: {
publishable: { enabled: true }
renderable: {
enabled: true
data: { metaTitleKey: "name", metaDescriptionKey: "summary" }
}
onlineStore: {
enabled: true
data: { urlHandle: "designers", createRedirects: true }
}
}
}
) {
metaobjectDefinition { id type }
userErrors { field message code }
}
}
access: { storefront: PUBLIC_READ } is the line people forget. The MetaobjectStorefrontAccess enum has two values, NONE and PUBLIC_READ, and the theme editor only offers a metaobject’s fields as dynamic sources when the definition has storefront visibility. Leave it on NONE and the entries exist in the admin while the editor has nothing to connect.
For a definition that already exists, metaobjectDefinitionUpdate takes the same capabilities block through MetaobjectCapabilityUpdateInput, so adding the page capability later is one mutation rather than a rebuild.
What goes in templates/metaobject/{type}.json?
A JSON template like any other, named after the definition type: templates/metaobject/designer.json. Shopify’s theme architecture docs say the first template you create for a type becomes the default and is used automatically by every active entry of that type; alternates can be added afterwards and assigned per entry.
{
"sections": {
"profile": {
"type": "designer-profile",
"settings": {}
},
"products": {
"type": "featured-collection",
"settings": {
"title": "Pieces by this designer"
}
}
},
"order": ["profile", "products"]
}
Any section that is available to every template is available here too, which is how a merchant drops Dawn’s existing sections onto a designer page. But the docs are blunt about the flip side: merchants must add sections to the metaobject template to display content. An empty template is a valid template, and it renders a page with nothing on it.
Which entry to render comes from the URL, and the theme does not look it up. Inside the template, and in any section it includes, the metaobject object is the current entry. metaobject.system carries the bookkeeping (handle, id, type, template_suffix and url), grouped under system so a field you named url or type cannot collide with it. metaobject.system.url is only set when the definition has the online store capability, so it doubles as a check that you are on a page-capable type.
How does a section read the entry’s fields?
Field access is by key, the same as a metaobject reached through a product metafield. A dedicated section is the clearest way to see it.
{%- comment -%} sections/designer-profile.liquid {%- endcomment -%}
<article class="designer">
{%- if metaobject.portrait.value -%}
{{ metaobject.portrait.value | image_url: width: 800 | image_tag: loading: 'eager' }}
{%- endif -%}
<h1>{{ metaobject.name }}</h1>
<p class="designer__summary">{{ metaobject.summary }}</p>
{{ metaobject.bio | metafield_tag }}
{%- if metaobject.products.value.size > 0 -%}
<ul class="designer__products">
{%- for product in metaobject.products.value -%}
<li><a href="{{ product.url }}">{{ product.title }}</a></li>
{%- endfor -%}
</ul>
{%- endif -%}
</article>
{% schema %}
{
"name": "Designer profile",
"settings": []
}
{% endschema %}
Two of those lines deserve a note. metaobject.name on its own prints the field’s value, which is fine for text; for the file_reference and list.product_reference fields you want .value, because that is what hands you the image and product objects rather than their ids. And metafield_tag on the rich_text_field is what turns the stored JSON into HTML.
Merchants can get the same fields without a custom section. In the theme editor, a text or image setting on any section in the template can be connected to the current entry’s fields as a dynamic source, provided the field type matches the setting type (a single_line_text_field into a text setting, a file_reference into an image_picker). The dynamic sources reference has the full compatibility table. That path keeps the whole thing in the editor, at the cost of the merchant rebuilding the bindings every time they add a section.
Why does the page come back empty, or 404?
Check three things, in this order, before you touch the theme code.
The entry is a draft. With publishable enabled, Liquid returns nil for any entry whose status is DRAFT, and only ACTIVE entries are served. New entries default to DRAFT, so an entry created by API without a follow-up metaobjectUpdate to set capabilities.publishable.status to ACTIVE looks exactly like a broken reference. Check the status before you check the template.
The template exists but has no sections. See above: the page is there, the entry is there, and the merchant added nothing to it. In the editor, open a page for that type and add a section.
The entry is on a different template. Each entry can carry a templateSuffix through MetaobjectCapabilityDataOnlineStoreInput, which Liquid surfaces as metaobject.system.template_suffix. If someone assigned a suffix for a template that only exists on a development theme, the live theme has nothing to render that entry with. Querying the entry’s capabilities { onlineStore { templateSuffix } } from Admin tells you what it expects; ls templates/metaobject/ on the live theme tells you what it has.
How do I list every designer on one page?
The index page is a separate problem, and the Liquid reference solves it with metaobjects.designer.values, the array of entries for a definition. Looking entries up one handle at a time is capped at 20 unique handles per page with no pagination, so the index has to go through values and paginate.
{% paginate metaobjects.designer.values by 24 %}
<ul class="designers">
{%- for designer in metaobjects.designer.values -%}
<li>
<a href="{{ designer.system.url }}">{{ designer.name }}</a>
</li>
{%- endfor -%}
</ul>
{{ paginate | default_pagination }}
{% endpaginate %}
The metaobject_definition reference currently gives two numbers on the same page: a maximum of 50 entries per loop, and pagination on values up to 250 entries per page. Test with your real entry count before promising a merchant a 300-designer index. values_count gives you the total without iterating.
If the entries come from product metafields instead, the metaobject_reference route in our metaobjects versus metafields comparison covers the lookup; this post is about the page, not the join.
When would I not do this?
Do it when the content is a catalogue of things that deserve a URL each: designers, stockists, ingredients, store locations, recipes, FAQs that should rank. The merchant edits them under Content in the admin, the theme renders them, and an app can create them in bulk with metaobjectUpsert by handle.
Skip it when the pages need per-page layout rather than per-page data. A metaobject template is one layout for every entry of the type; alternates stretch that to a handful, but a landing page that needs its own section arrangement is still a page template. Skip it too when the content is long-form editorial with comments and an RSS feed, because that is what blog articles are and metaobjects will not give you either.
And if the type is for a headless storefront, the same two capabilities feed the Storefront API’s Metaobject.onlineStoreUrl and Metaobject.seo fields, so the data model carries across; only the rendering half of this post stays in the theme.
Whoooop builds Shopify themes and the custom data models behind them, including definition migrations where a merchant’s pages are trapped in a page builder app and should be metaobjects. If that is where your store is, our Shopify development work starts with the content model.