Section schema
The host side — how a section accepts, limits, and presets 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.
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.
A theme block schema accepts only these six keys. Any other key fails validation when the theme is imported, saved, or rendered.
| Key | Type | Purpose |
|---|---|---|
name | string | Block name shown in the editor’s add-block list |
settings | array | The block’s settings |
blocks | array | Other theme block types this block may nest (see Nested blocks) |
presets | array | Default settings and child blocks applied when a merchant adds the block |
tag | string | null | Wrapper element (default div); null means no wrapper |
class | string | CSS class added to the wrapper element |
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._ (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.content_forTheme 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.
{% 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>{% 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.
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" }]id may contain only letters, numbers, _, or -.id in the same immediate parent are not allowed (duplicate static block id). Give each a unique id.{% content_for 'blocks' %} — they only render where you place their explicit content_for 'block' tag.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:
exceeds the maximum nested theme block depth of 8.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.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 %}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.*.
This builds one card block that several sections can host — a featured-content
band, a footer column, anywhere a section renders blocks.
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 }}.
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" }]}]Render them in that section’s markup with the dynamic tag:
<div class="card-grid"> {% content_for 'blocks' %}</div>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.
| Message | Cause and fix |
|---|---|
Theme blocks can't define local blocks | A 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 blocks | A 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 id | Two static blocks share an id under the same parent. Make each id unique. |
exceeds the maximum nested theme block depth of 8 | Blocks 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. |
Section schema
The host side — how a section accepts, limits, and presets blocks.
Setting types
Every setting type you can put in a block’s settings array.
Theme architecture
Where blocks/ fits in the overall theme directory layout.
Dynamic sources
Bind block settings to live data with dynamic sources.