Skip to content

Theme blocks

A theme block is a reusable building block defined once in its own file and hosted by many sections. Where a section-local block lives inside one section and only that section can render it, a theme block is a standalone blocks/<name>.liquid file with its own {% schema %}. Any section that opts in can host it, blocks can nest inside other blocks, and merchants rearrange them in the visual editor without touching code.

This is the most powerful composition tool in the theme system. Build a small library of well-made blocks once, and every section that accepts them gets richer without new section code.

Anatomy of a theme block file

A theme block is a .liquid file in the blocks/ directory. It renders HTML using {{ block.settings.* }} and ends with one {% schema %}. Here is a real block from the default theme:

<div class="theme-block-heading theme-block-heading--{{ block.settings.alignment }}">
{% if block.settings.kicker != blank %}<p class="section-kicker">{{ block.settings.kicker | escape }}</p>{% endif %}
{% if block.settings.heading != blank %}<h2 class="section-title">{{ block.settings.heading }}</h2>{% endif %}
</div>
{% schema %}
{
"name": "Heading",
"tag": "div",
"class": "theme-block-heading-wrap",
"settings": [
{ "type": "text", "id": "kicker", "label": "Kicker" },
{ "type": "inline_richtext", "id": "heading", "label": "Heading", "default": "Featured picks" },
{ "type": "text_alignment", "id": "alignment", "label": "Alignment", "default": "left" }
],
"presets": [{ "name": "Heading" }]
}
{% endschema %}

The block type is its filename without the extension — blocks/heading.liquid is the type heading. Sections reference it by that name.

Schema keys

A theme block schema accepts only these six keys. Any other key fails validation when the theme is imported, saved, or rendered.

KeyTypePurpose
namestringBlock name shown in the editor’s add-block list
settingsarrayThe block’s settings
blocksarrayOther theme block types this block may nest (see Nested blocks)
presetsarrayDefault settings and child blocks applied when a merchant adds the block
tagstring | nullWrapper element (default div); null means no wrapper
classstringCSS class added to the wrapper element

Accepting theme blocks in a section

A section opts in to hosting theme blocks through its own blocks array. List explicit types, or use the @theme wildcard to accept every public block:

"blocks": [
{ "type": "heading" },
{ "type": "@theme" }
]
  • { "type": "@theme" } accepts any public theme block in blocks/.
  • { "type": "heading" } accepts only that one block type.
  • A filename starting with _ (e.g. blocks/_product-card.liquid) is private: it is not matched by @theme and must be referenced by explicit type. Use the _ prefix for helper blocks you don’t want merchants to add freely.

Rendering blocks with content_for

Theme blocks are not rendered by iterating section.blocks. They are rendered by the content_for tag, which reads the saved block tree and renders each block’s file for you.

Render all blocks (dynamic)

{% content_for 'blocks' %} renders every enabled, non-static block the merchant has added, in their saved order. This is what makes a section a drop zone:

<div class="section-stack">
{% content_for 'blocks' %}
</div>

Render one fixed block (static)

{% content_for 'block', type: 'announcement', id: 'top-bar' %} renders a single block at a fixed position you control in code. Both type and id are required and must be plain string literals — embedded Liquid like {{ ... }} in the type or id is rejected.

{% content_for 'block', type: 'announcement', id: 'top-bar' %}
<div class="hero">
{% content_for 'blocks' %}
</div>

The type must be a block the current section (or parent block) actually targets in its schema — you can’t statically render a block the host doesn’t accept.

Static blocks in presets

A static block has a fixed position you set in code and can’t be removed or reordered by the merchant. Declare it in the section’s blocks array with "static": true and a stable id, then render it with the static content_for form above:

"blocks": [
{ "type": "announcement", "static": true, "id": "top-bar" },
{ "type": "@theme" }
]
  1. The static block’s settings are still merchant-editable in the visual editor — only its position is locked.
  2. An id may contain only letters, numbers, _, or -.
  3. Two static blocks with the same id in the same immediate parent are not allowed (duplicate static block id). Give each a unique id.
  4. Static blocks are skipped by {% content_for 'blocks' %} — they only render where you place their explicit content_for 'block' tag.

Nested theme blocks

A theme block can host other theme blocks by declaring its own blocks array and calling {% content_for 'blocks' %} in its markup. The default theme’s group block works this way — it accepts any block with { "type": "@theme" } and renders the children in a flex container. A trimmed version looks like this:

<div class="theme-block-group" style="--group-gap: {{ block.settings.gap }}px;">
{% content_for 'blocks' %}
</div>
{% schema %}
{
"name": "Group",
"settings": [
{ "type": "range", "id": "gap", "label": "Gap", "min": 0, "max": 48, "step": 4, "unit": "px", "default": 12 }
],
"blocks": [{ "type": "@theme" }],
"presets": [{ "name": "Group" }]
}
{% endschema %}

Two limits guard the block tree:

  • Maximum nesting depth is 8. Declaring presets that nest deeper fails import with exceeds the maximum nested theme block depth of 8.
  • A section renders at most 50 top-level blocks. A section’s max_blocks schema value must itself be between 0 and 50, and at render time the section’s directly-hosted dynamic blocks are capped at the lower of max_blocks and 50. Per-type limit entries in the section’s blocks array still apply on top of that cap.

The shopify_attributes requirement

{{ block.shopify_attributes }} is what lets the visual editor select, highlight, and reorder a block on the canvas. When a block uses a normal tag, the renderer emits a wrapper element and adds these attributes for you automatically.

When you set "tag": null the renderer adds no wrapper at all — your block’s own top-level element becomes the block element. In that case you must print {{ block.shopify_attributes }} on that single top-level element yourself, or the import fails with a message telling you exactly that:

<button class="custom-cta" {{ block.shopify_attributes }}>
{{ block.settings.label }}
</button>
{% schema %}
{ "name": "CTA button", "tag": null, "settings": [
{ "type": "text", "id": "label", "label": "Label", "default": "Shop now" }
]}
{% endschema %}

Binding to the nearest resource with closest.*

Theme blocks are resource-agnostic, so a block placed on a product page should be able to read that product without hard-coding it. The closest.* objects resolve to the nearest resource in the render context — closest.product, closest.collection, and so on:

<h2>{{ closest.product.title }}</h2>
<p>{{ closest.product.price | money }}</p>

The same block reused on a collection page can read closest.collection. You can also feed a resource explicitly when rendering: {% content_for 'blocks' %} and the static form accept closest.* named arguments (for example {% content_for 'blocks', closest.product: card.product %}), and those are the only named arguments content_for 'blocks' accepts besides context.*.

Worked example: a reusable card block

This builds one card block that several sections can host — a featured-content band, a footer column, anywhere a section renders blocks.

  1. Create the block file at blocks/card.liquid with its markup and schema:

    <a href="{{ block.settings.link }}" class="card" {{ block.shopify_attributes }}>
    {% if block.settings.image %}
    <img src="{{ block.settings.image | image_url: width: 600 }}" alt="{{ block.settings.heading | escape }}" loading="lazy">
    {% endif %}
    <h3>{{ block.settings.heading }}</h3>
    <p>{{ block.settings.body }}</p>
    </a>
    {% schema %}
    {
    "name": "Card",
    "tag": null,
    "settings": [
    { "type": "image_picker", "id": "image", "label": "Image" },
    { "type": "text", "id": "heading", "label": "Heading", "default": "New arrival" },
    { "type": "richtext", "id": "body", "label": "Text" },
    { "type": "url", "id": "link", "label": "Link" }
    ],
    "presets": [{ "name": "Card" }]
    }
    {% endschema %}

    Because tag is null, the <a> element is the block element, so it carries {{ block.shopify_attributes }}.

  2. Let a section host it. In any section’s schema, accept theme blocks:

    "blocks": [{ "type": "@theme" }],
    "presets": [{
    "name": "Card row",
    "blocks": [{ "type": "card" }, { "type": "card" }, { "type": "card" }]
    }]
  3. Render them in that section’s markup with the dynamic tag:

    <div class="card-grid">
    {% content_for 'blocks' %}
    </div>
  4. Reuse anywhere. Add { "type": "@theme" } (or { "type": "card" }) to any other section and it can host the same card with no extra code. The preset seeds three cards whenever a merchant adds the section.

Troubleshooting

MessageCause and fix
Theme blocks can't define local blocksA blocks entry in a blocks/*.liquid file has a name or settings. Reference theme blocks by type only.
a section cannot mix section-defined blocks with theme blocksA section’s blocks array has both local definitions and theme type references. Pick one kind.
cannot be rendered conditionally or in a for-loop{% content_for 'blocks' %} sits inside an if/for/case. Move it to a fixed, unconditional position.
duplicate static block idTwo static blocks share an id under the same parent. Make each id unique.
exceeds the maximum nested theme block depth of 8Blocks nest deeper than 8 levels. Flatten the structure.
the block file must include {{ block.shopify_attributes }}A block uses "tag": null but doesn’t print block.shopify_attributes on its top-level element. Add it.

Next steps

Setting types

Every setting type you can put in a block’s settings array.