Sections & blocks

Sections and blocks are the building units of a page — a Shopify-style content model. A theme defines the types that exist; content instances arrange and fill them in.

Two parts of every component

Each .liquid file in sections/ or blocks/ has two parts:

  1. Markup — ordinary Liquid/HTML.
  2. A settings schema, embedded in a {% schema %} ... {% endschema %} block. This is never a live Liquid tag — it’s stripped out by the CMS before rendering, so it can live anywhere in the file (convention: the end). The content must be a single valid JSON object: a JSON Schema draft-07 document describing that component’s settings.

It’s real JSON Schema, validated with Ajv: "type": "object", "additionalProperties": false, an explicit "required" array, and a "properties" object using ordinary types (string, integer, number, boolean, array, object) with constraints like minLength, minimum, maximum, or enum. On top of that, a "format" on a property tells the admin which editor to show for it — a link picker, an image picker, a colour, a slider — see Field types below.

Only these keywords are supported: type, properties, required, additionalProperties, default, minLength, maxLength, minimum, maximum, pattern, enum, items, minItems, maxItems, plus the Minamo-specific format, title, description, group, allowedBlocks, api, swatches, step and unit. Never $ref, $defs, allOf, anyOf, oneOf, not or if/then/else — write each property’s schema out in full where it’s used.

Every required property needs a default

Every property listed in "required" must also declare a "default", and that default must itself satisfy the property’s own constraints — "default": "" against "minLength": 1 doesn’t count. This is what lets the admin pre-fill a newly added section so it starts valid instead of empty. A type that violates this is excluded from the theme with a start-up warning, the same way a malformed {% schema %} block is — it won’t appear as an option in the admin until fixed.

Naming a section or block for the admin

Give every section and block a "title" and a "description" on the schema object, alongside "properties". Neither affects validation; the admin’s Add a Section dialog lists one row per type, showing the title with the description beside it:

JSON
{
  "title": "Image band",
  "description": "A full width photograph with an optional caption.",
  "type": "object",
  "properties": { }
}

Write the description as one short sentence about what the section is, not which fields it has. Without a title the filename is shown instead (image-band), which reads as code rather than a choice. A property’s own "title" is its field label in the admin; without one, the key is turned into words (backgroundImage → “Background Image”).

Field types

The type, and optionally a "format", decide which editor the admin shows. A format is a display hint only — it’s never enforced as validation.

formatOn typeWhat the editor gets
(none)stringA single-line text box.
textareastringA multi-line text box.
richtextstringA rich-text editor (paragraphs, headings, bold, italic, lists, links). Render it with | raw.
urlstringA link. The admin suggests the site’s own pages as the editor types (by title or path) and stores the chosen page’s path, e.g. /about; any external address, mailto:, tel: or #anchor can be typed instead. Under a site path it shows which page it reaches, or warns that none is there.
datestringA date picker; the value is YYYY-MM-DD.
colorstringA colour picker storing a hex value such as "#ff6600". An optional sibling "swatches": ["#c2410c", ...] offers a preset palette; a custom colour is still allowed.
rangeinteger/numberA slider with a number box. Needs minimum and maximum; optional "step" (default 1) and "unit" (e.g. "px").
(none)booleanA checkbox.
togglebooleanA switch instead of a checkbox (the same true/false value).
(none)string + enumSegmented tabs for a few short choices, or a dropdown for more or longer ones — chosen automatically.
imageobjectAn image picker from the media library, with a focal point. The value is exactly { "url", "focalX", "focalY" }.
videoobjectA video picker for a short, silent background loop. The value is exactly { "url", "poster" }.
(none)array of stringA list of text lines with add, remove and drag to reorder. minItems/maxItems bound the number of lines.
(none)array of image objectsA gallery: a grid of images with add, remove and drag to reorder. Each item is the same { "url", "focalX", "focalY" } shape.

This is a closed set: they’re the only shapes the admin has a real editor for. A repeating item with more than one field of its own (an image with a caption, a card) is a block type, not a wider array setting. For a link that should be able to open in a new window, add your own boolean field beside the url field. "format": "uri", the link format’s older name, still works; npm run check suggests renaming it.

Rendering images and video

Render every image and video setting through the responsive-media snippet every new site ships with. It writes the responsive markup, the focal point or poster, and the data-cms-media attribute the admin needs before an editor can drag a new image from the media library onto the preview:

Liquid
{% if section.settings.image.url %}
  {% render 'responsive-media',
       media: section.settings.image,
       kind: 'image',
       field: 'image',
       alt: 'What is actually in the picture',
       ratio: '16 / 9' %}
{% endif %}

Pass the whole setting as media (not its .url), and the schema property name as field — a drop writes the new image into exactly that setting. A video is the same call with kind: 'video'. Use loading: 'eager' and priority: true for the one image at the top of the page. A hand-written <img> renders perfectly but can never be replaced by dragging, which is easy to miss.

Grouping fields

Give related fields the same "group" and the admin shows them together under one collapsible heading: ungrouped fields first, then each group in the order its first field appears, with the first group open. It changes only the admin’s layout — the setting’s name and how a template reads it stay the same — and works the same way in site settings.

JSON
"properties": {
  "heading": { "type": "string", "title": "Heading" },
  "backgroundColor": {
    "type": "string", "format": "color", "title": "Colour", "group": "Background"
  },
  "backgroundImage": {
    "type": "object", "format": "image", "title": "Image", "group": "Background"
  }
}
  • Leave the fields an editor changes most ungrouped, at the top — a heading, the text, the main image or link.
  • Only group once there are more than about five or six fields, with at least two in each group; a group of one is just an extra click.
  • Name groups by what they affect with short nouns — Layout, Background, Button, Advanced — and use the same name for the same idea in every component.
  • Put the most-used group first, since it’s the one that starts open.

Section markup — available variables

Liquid
{{ section.id }}              the section instance's id (string)
{{ section.settings.<key> }}  a setting value, per this section's own schema
{{ settings.<name> }}         a site setting - see Site settings
{% for html in blocksHtml %}    pre-rendered child blocks (output with | raw)

blocksHtml is an array of already-rendered HTML strings — each block is rendered separately, recursively, before the section itself. A section template never loops over raw block settings directly; it only receives finished HTML per block, output with | raw. The engine auto-escapes {{ }} by default — | raw is the explicit opt-out for content the CMS already produced as safe HTML (blocksHtml, and a richtext field). Never apply it to an ordinary setting.

Restricting which block types are allowed

By default, any block type in the theme can be added under any section or block that renders blocksHtml. To restrict a section to specific child types, add an "allowedBlocks" array to its schema, alongside "properties":

JSON
{
  "type": "object",
  "additionalProperties": false,
  "required": ["heading"],
  "properties": {
    "heading": { "type": "string", "minLength": 1, "default": "New section" }
  },
  "allowedBlocks": ["button"]
}

Each entry is a child block type’s identifier (its filename without .liquid). Omit "allowedBlocks" entirely for no restriction. A saved instance whose blocks array contains a type not listed here is rejected with a structured validation error, the same way an invalid setting value is.

Queryable fields

Flag any individual property as "api": true to expose its value through the agent’s search index, independent of full-text search — useful for something like filtering a product listing by price. Only scalar values (string, number, boolean) are meaningful this way; an object/array-typed property flagged this way is ignored.

JSON
{
  "type": "object",
  "properties": {
    "price": { "type": "number", "api": true }
  }
}

That field becomes queryable via GET /search.json?filter=price:lt:50 — see Querying your site for the full query parameters (a no-token, public endpoint a theme’s own front-end can call directly). Each section/block instance is indexed independently, so a page with several matching instances of the same field only needs one to satisfy a query.

Worked example — sections/hero.liquid

Liquid
<section class="hero" data-section-id="{{ section.id }}">
  {% if section.settings.image.url %}
    {% render 'responsive-media', media: section.settings.image, kind: 'image',
         field: 'image', alt: section.settings.heading, ratio: '21 / 9',
         loading: 'eager', priority: true %}
  {% endif %}
  <h1>{{ section.settings.heading }}</h1>
  {% if section.settings.subheading %}
    <p>{{ section.settings.subheading }}</p>
  {% endif %}
  <div class="hero__blocks">
    {% for html in blocksHtml %}{{ html | raw }}{% endfor %}
  </div>
</section>
{% schema %}
{
  "title": "Hero",
  "description": "A large opening banner with a heading, photo and buttons.",
  "type": "object",
  "additionalProperties": false,
  "required": ["heading"],
  "properties": {
    "heading": { "type": "string", "minLength": 1, "default": "New section" },
    "subheading": { "type": "string", "format": "textarea", "default": "" },
    "image": { "type": "object", "format": "image" }
  },
  "allowedBlocks": ["button"]
}
{% endschema %}

Worked example — blocks/button.liquid

Liquid
<a class="button" data-block-id="{{ block.id }}" href="{{ block.settings.url }}">
  {{ block.settings.label }}
</a>
{% schema %}
{
  "title": "Button",
  "description": "A link styled as a button.",
  "type": "object",
  "additionalProperties": false,
  "required": ["label", "url"],
  "properties": {
    "label": { "type": "string", "minLength": 1, "default": "Click here" },
    "url": { "type": "string", "format": "url", "minLength": 1, "default": "/" }
  }
}
{% endschema %}

Block markup has the same shape: {{ block.id }}, {{ block.settings.<key> }}, and blocksHtml again if this block type itself nests further blocks (rare, but supported).

How content actually references a type

A page’s content JSON references a section or block by its type identifier, with settings matching that component’s own schema:

JSON
{
  "id": "sec-hero",
  "type": "hero",
  "settings": {
    "heading": "Welcome",
    "subheading": "A short line about the site"
  },
  "blocks": [
    {
      "id": "blk-cta",
      "type": "button",
      "settings": { "label": "Get started", "url": "/about" }
    }
  ]
}

Every instance requires id (any non-empty string, unique within the page), type (must exactly match a filename in sections//blocks/), and settings (matching that type’s schema). blocks is optional and only meaningful if the section actually loops over blocksHtml. See The content model for the full page schema.