DEV Community

RAXXO Studios
RAXXO Studios

Posted on • Originally published at raxxo.shop

Shopify Metaobjects Schema Patterns: 5 Real-World Models

  • FAQ entries with question, answer, category, and rank fields drive a single accordion section across the whole store

  • Reviews use author, quote, rating, source, and date with an Image field for headshots, queried by source for filtering

  • Team members combine name, role, photo, bio, and a list of social URLs, rendered through a tiny Liquid loop

  • Case studies pack client, problem, solution, metric, and year into one definition that powers both grid and detail pages

  • Bundle definitions reference parent and child products with a discount field, rendered with metafield references in Liquid

I redesigned the same five sections across three Shopify stores last month and noticed I was rebuilding the same five metaobject shapes from scratch each time. So I wrote them down. This is the playbook I now copy into every new build, with definition fields, GraphQL queries, and the Liquid I actually ship. Honest about the limits too, because metaobjects are not free.

Pattern 1: FAQ Entries

Every store needs FAQs and every store builds them differently. The metaobject definition that has held up across three projects:

  • question (single-line text, required)

  • answer (multi-line text, required)

  • category (single-line text, used as a tag for filtering)

  • rank (integer, optional, default 100, lower numbers float to the top)

I keep the type slug as faq_entry. The category field stays a plain string instead of a reference to another metaobject, because the FAQ list rarely outgrows ten categories and I want editors to type Shipping without hunting through a picker.

GraphQL to read the full set, sorted by rank then question:


query Faqs {
  metaobjects(type: "faq_entry", first: 100) {
    nodes {
      handle
      question: field(key: "question") { value }
      answer: field(key: "answer") { value }
      category: field(key: "category") { value }
      rank: field(key: "rank") { value }
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

Liquid render in a section, grouped by category with a `` accordion:

`liquid

{% assign faqs = shop.metaobjects.faq_entry.values | sort: 'rank' %}
{% assign cats = faqs | map: 'category' | uniq %}
{% for cat in cats %}

{{ cat }}

{% for f in faqs %}
{% if f.category == cat %}

    {{ f.question }}
Enter fullscreen mode Exit fullscreen mode

{{ f.answer }}

{% endif %}
Enter fullscreen mode Exit fullscreen mode

{% endfor %}
{% endfor %}

`

One FAQ definition feeds product pages, the help page, and the chat widget. Editors add an entry once and it appears in three places. That alone earns metaobjects their keep.

Pattern 2: Review and Testimonial

I used to hardcode reviews into a section's schema as block presets. Editors hated it because you cannot reuse a review across pages. The metaobject version takes about ten minutes longer to set up and saves hours forever.

Definition slug testimonial, fields:

  • author (single-line text)

  • quote (multi-line text)

  • rating (integer, 1 to 5)

  • source (single-line text, values like Shopify, Trustpilot, Email, Direct)

  • date (date)

  • headshot (file reference, image only)

GraphQL to pull only verified Trustpilot reviews:

`graphql

query Reviews {
metaobjects(type: "testimonial", first: 50, query: "source:Trustpilot") {
nodes {
author: field(key: "author") { value }
quote: field(key: "quote") { value }
rating: field(key: "rating") { value }
headshot: field(key: "headshot") { reference { ... on MediaImage { image { url altText } } } }
}
}
}

`

The query parameter on the metaobjects connection is the part nobody talks about. I filter by source on the social proof rail, by rating greater than four for the homepage strip, and pull the full set on the dedicated reviews page. Same data, three angles. Liquid to render a single card:

`liquid

{% for t in shop.metaobjects.testimonial.values %}
{% if t.rating >= 4 %}

  {%- if t.headshot != blank -%}
    ![{{ t.author }}]({{%20t.headshot%20|%20image_url:%20width:%2080%20}})
  {%- endif -%}
  > {{ t.quote }}
  {{ t.author }} via {{ t.source }}
Enter fullscreen mode Exit fullscreen mode

{% endif %}
{% endfor %}

`

If you came at this from the CMS-replacement side, the deeper context lives in Shopify Metaobjects Killed My Headless CMS in One Saturday. This article is about the shapes themselves.

Pattern 3: Team Member

The team page used to be a Shopify page with a hardcoded HTML grid. Adding someone meant editing raw HTML. Now it is a metaobject with one definition powering both the about page and the contributors section in the blog.

Type slug team_member, fields:

  • name (single-line text)

  • role (single-line text)

  • photo (file reference, image)

  • bio (multi-line text)

  • socials (list of single-line text, stores URLs)

  • email (single-line text, optional, used for editor contact only)

The socials list is the trick. Editors paste any number of profile URLs (LinkedIn, GitHub, X, Bluesky, Mastodon, personal site) and the Liquid loop renders an icon based on the hostname:

`liquid

{% for member in shop.metaobjects.team_member.values %}

![{{ member.name }}]({{%20member.photo%20|%20image_url:%20width:%20240%20}})
Enter fullscreen mode Exit fullscreen mode

{{ member.name }}

{{ member.role }}

{{ member.bio }}

  {% for url in member.socials.value %}
    {% assign host = url | split: '/' | slice: 2, 1 | first | replace: 'www.', '' %}
    - [{{ host }}]({{%20url%20}})

  {% endfor %}
Enter fullscreen mode Exit fullscreen mode

{% endfor %}

`

Why a list of strings instead of separate fields per platform? Because the day you add a new platform, you do not want to migrate the schema. List-of-text scales. Per-platform fields turn into archaeology fast.

Pattern 4: Case Study

This is the pattern that surprised me. I built it for one client portfolio and now I use it for product proof pages too, swapping client for customer in my head.

Type slug case_study, fields:

  • client (single-line text)

  • problem (multi-line text)

  • solution (multi-line text)

  • metric (single-line text, free-form like 47% faster checkout or saved 12 hours per week)

  • year (integer)

  • cover (file reference, image)

  • featured_products (list of product references)

The featured_products field is the part that makes this lock into Shopify properly. A case study page can render the actual products it references, with live pricing, real-time stock, and a Buy button, all from one definition. Section schema patterns alone cannot do that, which is one reason I keep Shopify Section Schema Patterns Editors Actually Love and metaobjects in the same toolbox.

GraphQL on a single case study page, by handle:

`graphql

query Case($handle: String!) {
metaobject(handle: { type: "case_study", handle: $handle }) {
client: field(key: "client") { value }
problem: field(key: "problem") { value }
solution: field(key: "solution") { value }
metric: field(key: "metric") { value }
products: field(key: "featured_products") {
references(first: 10) {
nodes { ... on Product { id title handle priceRange { minVariantPrice { amount } } } }
}
}
}
}

`

For the index grid, I render a card with metric front and center, because that is what gets the click. Big number, small everything else.

Pattern 5: Bundle Definition

Bundles are the messiest pattern in the set, because Shopify already has its own bundle product type, but I want the editor experience to be defining a bundle without creating a new SKU. Metaobjects let me describe a bundle as data, then render it on a parent product page.

Type slug bundle, fields:

  • parent_product (product reference, single)

  • included_products (list of product references)

  • discount_percent (integer, 0 to 100)

  • discount_label (single-line text, like Save 19%, used in UI when calculation does not fit)

  • note (multi-line text, optional, for editor disclaimers)

The bundle does not replace your checkout logic. That still lives in a Shopify Function or a discount automatic rule. The metaobject is the descriptor, not the enforcer. Liquid block on the parent product page:

`liquid

{% assign bundle = shop.metaobjects.bundle.values | where: 'parent_product', product | first %}
{% if bundle %}

Buy together and save {{ bundle.discount_percent }}%

  {% for p in bundle.included_products.value %}
    - {{ p.title }} ({{ p.price | money }})

  {% endfor %}


{% if bundle.note != blank %}
Enter fullscreen mode Exit fullscreen mode

{{ bundle.note }}
{% endif %}

{% endif %}

`

Now editors define bundles in admin without touching code, and developers wire the actual discount once. That separation is what saves you a year from now, when product changes its mind about what counts as a bundle.

The Limits I Keep Hitting

Honest answer time. Metaobjects are great until they are not. Three real ceilings:

  1. The 50 entry threshold. Querying more than ~50 metaobjects of one type from a Liquid context starts to drag the page. I have seen 80 FAQ entries push storefront response from 180ms to 600ms. If you have more than 50 of anything, paginate or pre-render.

  2. List-of-references is read-only in Liquid. You can render the references but you cannot filter them at the storefront layer with anything beyond where. Heavy filtering belongs in GraphQL or a backend.

  3. No deep validation. Metaobjects do not enforce regex, range checks beyond integer min/max, or cross-field rules. If editors must enter clean data, validate at the entry point with a custom admin app or a Shopify Flow webhook.

None of these are deal breakers. They mark the line where you stop reaching for metaobjects and start reaching for a real backend. Know the line, save yourself a rebuild eighteen months from now.

Bottom Line

Five definitions, copied into every new build, save me roughly a day per project. FAQ entries, testimonials, team members, case studies, and bundle descriptors cover something like 80% of the structured content I see editors fight with. The other 20% is bespoke and gets a custom definition.

Pin these as templates, name them consistently across stores, and resist the urge to add a sixth field every time. Constraints make schemas usable. The trade between expressive and editable is real, and editable wins more often than developers think.

If you want the hub view of every Shopify Dev article in this corpus, browse the Shopify Dev section of the lab overview. The full /now slice of what I am shipping next sits under the same roof.

Top comments (0)