Building nested blocks in Shopify sections
If you have spent any significant time developing themes for Shopify Online Store 2.0, you have likely encountered the need to nest blocks in Section Blocks. While Shopify's JSON templates and Section architecture are robust, they lack one specific feature that developers coming from Component-based frameworks (like React or Vue) desperately miss: true block nesting.
In the Shopify Schema, section blocks are a flat array. A block cannot contain another block (unless it's a theme block). You cannot natively drag a "Text Block" inside a "Column Block" in the Theme Editor.
This limitation makes creating complex UI patterns, like Mega Menus, Accordions, or Tab sets, frustratingly difficult without resorting to hard-coded HTML or rigid setting limits.
However, with a clever manipulation of Liquid’s rendering logic, we can bypass this limitation. By utilizing Placement Blocks and state-based rendering loops, we can simulate a nested hierarchy using the linear list provided by the Shopify Admin.
The Problem: The Linear Array
To understand the solution, we must first diagnose the limitation. When you define a section schema, you define a blocks array. In the Theme Editor, this renders as a single, sortable list.
From a data perspective, section.blocks looks like this:
[
{ "type": "heading", "id": "A", "settings": {} },
{ "type": "text", "id": "B", "settings": {} },
{ "type": "image", "id": "C", "settings": {} },
{ "type": "heading", "id": "D", "settings": {} },
{ "type": "text", "id": "E", "settings": {} }
]
In a standard loop ({% for block in section.blocks %}), these render sequentially. But what if logically, Block B and Block C are supposed to be inside Block A? And Block E is supposed to be inside Block D?
Without nesting, developers often resort to creating massive "Mega Blocks" with dozens of settings (e.g., "Slide 1 Image", "Slide 1 Text", "Slide 2 Image"...), which is unmaintainable and poor UX.
The Solution: Placement Logic
The solution is to change how we perceive the Loop. Instead of rendering every block as it appears, we treat specific blocks as Parents (or "Placement Markers") and subsequent blocks as Children, based on their position in the list.
We can achieve this by utilizing a "State Flag" logic in Liquid. We iterate through the block list, and when we encounter a Parent, we "open the gate" to render subsequent blocks. We keep that gate open until we hit the next Parent block, which "closes the gate."
The Logic Flow
- Identify the Parent: We pick a block (e.g., a Menu Item) that acts as the container.
- Iterate the List: We loop through
section.blocks. - Trigger Start: When the loop hits our identified Parent, we set a variable
should_rendertotrue. - Render Children: As the loop continues to the next items, because
should_renderis true, we render them. - Trigger Stop: If the loop hits a block that is another Parent type, we set
should_rendertofalse, stopping the output.
The Implementation
Let's look at a practical implementation. Imagine we are building a Mega Menu. We have menu-item blocks (Parents) and icon or promotion blocks (Children) that should appear inside specific menu items.
Here is the core logic snippet. This logic assumes we are currently inside a loop of the "Parents" and we need to find the "Children" for the current parent (curr_menu_item).
{% comment %} Initialize variables {% endcomment %}
{% assign should_render = false %}
{% assign curr_rendered = false %}
{% for block in section.blocks %}
{% comment %} Check if we have hit the Parent Block we are currently looking for.{% endcomment %}
{% if block.id == curr_menu_item.id %}
{% assign should_render = true %}
{% assign curr_rendered = true %}
{% comment %}
If we are currently rendering, but we hit a NEW parent (menu-item),
we must stop rendering immediately.
{% endcomment %}
{% elsif curr_rendered and block.type == 'menu-item' and block.id != curr_menu_item.id %}
{% assign should_render = false %}
{% endif %}
{% comment %}
Rendering nested blocks
{% endcomment %}
{% if should_render and block.id != curr_menu_item.id %}
{% case block.type %}
{% when 'icon' %}
<div class="menu-icon">
{{ block.settings.icon_image | image_url: width: 50 | image_tag }}
</div>
{% when 'promotion' %}
<div class="menu-promo">
<h3>{{ block.settings.promo_title }}</h3>
</div>
{% endcase %}
{% endif %}
{% endfor %}
Deep Dive: Analyzing the Snippet
Let's break down the magic occurring in the snippet above.
You will notice a variable curr_rendered. Why do we need this if we have should_render?
This acts as a confirmation that we have already passed the start point. Without this, if your Parent block is the 5th item in the list, the logic might get confused by other parents appearing before the 5th item. curr_rendered ensures we only start looking for the "Stop" clause after we have successfully found our "Start" block.
The Stop Clause
{% elsif curr_rendered and block.type == 'menu-item' and block.id != curr_menu_item.id %}
This is the most critical line. It detects the boundary of the loop. If the loop encounters another block of type menu-item, it knows the "scope" of the current parent has ended. Without this, the first parent would inherit all subsequent blocks in the section, even those meant for the second or third parent.
Architecture: The Outer Loop vs. The Inner Loop
To make this work in a real section (like a Tab system or Menu), you usually need Nested Loops.
- Outer Loop: Iterates to find only the "Parent" blocks to draw the structure (e.g., the Tab Navigation or Menu Links).
- Inner Loop: (The snippet above) iterates through all blocks again to find the children relevant to that specific parent.
Here is an example of a full Tabs Section might look using this architecture:
<div class="tabs-wrapper">
<div class="tab-headers">
{% for block in section.blocks %}
{% if block.type == 'tab_parent' %}
<button class="tab-link" onclick="openTab('{{ block.id }}')">
{{ block.settings.title }}
</button>
{% endif %}
{% endfor %}
</div>
<div class="tab-contents">
{% for parent_block in section.blocks %}
{% if parent_block.type == 'tab_parent' %}
<div id="{{ parent_block.id }}" class="tab-content-panel">
{% assign should_render = false %}
{% assign found_parent = false %}
{% for child_block in section.blocks %}
{% if child_block.id == parent_block.id %}
{% assign should_render = true %}
{% assign found_parent = true %}
{% continue %}
{% elsif found_parent and child_block.type == 'tab_parent' %}
{% assign should_render = false %}
{% endif %}
{% if should_render %}
{% case child_block.type %}
{% when 'text_row' %}
<p>{{ child_block.settings.text }}</p>
{% when 'image_row' %}
{{ child_block.settings.image | image_url: width: 400 | image_tag }} {% endcase %}
{% endif %}
{% endfor %}
</div>
{% endif %}
{% endfor %}
</div>
</div>
UX Implications for the Merchant
Technical implementation is only half the battle. Since we are "hacking" the visual hierarchy, we must communicate this clearly to the merchant, or they will be confused why dragging a block changes its ownership.
Best Practices for Merchant UX:
Naming Conventions In your schema, name the blocks clearly. Use Parent: Tab and Child: Text or use emoji indicators like 📂 Group and ↳ Item.
Schema Info Use the info attribute in your schema to explain the behavior. json { "type": "tab_parent", "name": "📂 Tab Heading", "settings": [ { "type": "header", "content": "Grouping Logic", "info": "Any 'Child' blocks placed below this Tab will belong to this Tab, until the next Tab block appears." } ] }
Theme Editor Inspector This method works beautifully with the Inspector (the visual preview). If a user clicks a "child" element in the preview, it highlights the block in the sidebar. Because the blocks are physically next to each other in the list, the context remains clear.
Performance Considerations
A keen observer might notice a potential performance issue: Algorithmic Complexity.
If you have a section with 50 blocks, and 10 of them are Parents, the nested loop structure implies that for every Parent, we iterate through the entire list of blocks again.
- Standard Loop: O(n)
- Nested Placement Loop: Roughly O(n2)
In the context of Liquid server-side rendering, iterating through 50 or 100 items is negligible (execution time is measured in milliseconds). However, if you are building a massive page with hundreds of blocks, you should be mindful of this.
You can use Shopify Theme Inspector for Chrome to analyze the impact of the logic code. This walkthrough of analyzing Liquid performance from Shopify will help you understand how to analyze your implementation.
Advantages of Placement Blocks
Why go through this trouble instead of just using hardcoded settings?
- Infinite Flexibility: A merchant can add 1 image, then 2 paragraphs, then a video, then another paragraph inside a single "Tab." With hardcoded settings, you are forced to predict exactly what fields they need.
- Native UI: It uses the native drag-and-drop interface. There are no 3rd party apps required, and no complex Metaobject definitions (though Metaobjects are another valid way to solve nesting).
- Cleaner UX: You avoid "Mega Blocks" with 50 settings. Your
textblock handles text. Yourimageblock handles images. Separation of concerns is maintained and easier for merchant to understand
Conclusion
The "Placement Block" pattern transforms the flat list of Shopify 2.0 into a hierarchical tree. By using a simple should_render state switch, we allow merchants to build complex, nested layouts—like accordions, timelines, and mega menus—using the simple drag-and-drop tools they already know.
While it requires a shift in mental model for the developer, the payoff is a significantly more flexible and powerful editing experience for the merchant.


