Most Shopify stores I audit are running 15-20 apps, a bloated theme, and Liquid templates that haven’t been optimized since the initial build. The result: 5-8 second load times, $50-300/month in unnecessary app fees, and a codebase that’s painful to maintain.
After building and optimizing 100+ Shopify stores over 12 years, I’ve developed a set of Liquid development practices that consistently produce faster, cleaner, more maintainable themes. This guide covers everything from architecture fundamentals to advanced performance techniques — with real code examples from production stores.
Why Liquid Still Matters in 2026 (Even with Hydrogen and Headless)
Every few months someone declares Liquid dead and headless the future. Here’s the reality: 95%+ of Shopify stores still run on Liquid themes, and that number isn’t changing anytime soon.
Shopify continues to invest heavily in Liquid and Online Store 2.0. Section Everywhere, improved section rendering APIs, new Liquid filters — the platform keeps getting more capable, not less. For the vast majority of DTC brands doing $500K-$10M in annual revenue, Liquid is the right technology choice.
Headless (Hydrogen/Oxygen) makes sense for a narrow set of use cases: brands with dedicated frontend engineering teams, complex multi-storefront architectures, or highly interactive experiences that genuinely can’t be built in Liquid. For everyone else, going headless means trading Shopify’s battle-tested hosting, CDN, and merchant admin for a custom frontend that costs 3-5x more to build and maintain.
The practical question isn’t “Liquid or headless?” — it’s “how do I write better Liquid?” That’s what this guide is for.
Shopify Liquid Architecture Fundamentals
Liquid operates on three core building blocks. Understanding these deeply is what separates junior Shopify developers from senior ones.
Objects output data from your store. Product titles, prices, collection descriptions, cart contents — all accessed through double curly braces:
{{ product.title }}
{{ product.price | money }}
{{ collection.description }}
{{ cart.total_price | money_with_currency }}
Tags create logic and control flow. Conditionals, loops, variable assignments, and template rendering all happen through tags:
{% if product.available %}
{% for variant in product.variants %}
{% if variant.available %}
<option value="{{ variant.id }}">{{ variant.title }} - {{ variant.price | money }}</option>
{% endif %}
{% endfor %}
{% else %}
<p>This product is currently sold out.</p>
{% endif %}
Filters transform output. They’re piped after objects using the | character:
{{ product.title | upcase }}
{{ product.price | money }}
{{ product.featured_image | image_url: width: 600 }}
{{ "now" | date: "%B %d, %Y" }}
{{ product.description | strip_html | truncatewords: 30 }}
The key insight most developers miss: Liquid is deliberately limited. It can’t make API calls, access the filesystem, or run arbitrary code. This constraint is what makes Shopify themes fast and secure by default. Work with the language, not against it.
Section Architecture Best Practices for 2026
This is where most Shopify developers go wrong. Bad section architecture is the root cause of the majority of theme maintenance problems I see on client audits.
Sections vs Snippets vs Blocks
Sections are the top-level building blocks. Each section has its own Liquid template, optional CSS/JS, and a JSON schema that powers the theme editor. Merchants can add, remove, reorder, and configure sections without touching code.
Snippets are reusable code fragments that you {% render %} from within sections. Think of them as components — a product card, a price display, an icon set. Snippets don’t have schemas and can’t be directly manipulated in the theme editor.
Blocks live inside sections and are defined in the section’s schema. They let merchants add repeatable content units within a section — like slides in a slideshow, features in a feature grid, or tabs in a product description.
The rule: Sections for page-level components, snippets for shared UI patterns, blocks for repeatable content within a section.
A Well-Structured Section Example
Here’s a pattern I use across client stores — a flexible content section with blocks that merchants can customize:
{% comment %} sections/featured-benefits.liquid {% endcomment %}
<section class="benefits-section" style="padding: {{ section.settings.padding_top }}px 0 {{ section.settings.padding_bottom }}px;">
<div class="container">
{% if section.settings.heading != blank %}
<h2 class="benefits-heading">{{ section.settings.heading }}</h2>
{% endif %}
<div class="benefits-grid benefits-grid--{{ section.settings.columns }}">
{% for block in section.blocks %}
<div class="benefit-card" {{ block.shopify_attributes }}>
{% if block.settings.icon != blank %}
<div class="benefit-icon">
{{ block.settings.icon | image_url: width: 64 | image_tag: loading: 'lazy' }}
</div>
{% endif %}
<h3>{{ block.settings.title }}</h3>
<p>{{ block.settings.description }}</p>
</div>
{% endfor %}
</div>
</div>
</section>
{% schema %}
{
"name": "Featured Benefits",
"settings": [
{
"type": "text",
"id": "heading",
"label": "Heading",
"default": "Why Choose Us"
},
{
"type": "select",
"id": "columns",
"label": "Columns",
"options": [
{ "value": "2", "label": "2 columns" },
{ "value": "3", "label": "3 columns" },
{ "value": "4", "label": "4 columns" }
],
"default": "3"
},
{
"type": "range",
"id": "padding_top",
"label": "Top padding",
"min": 0,
"max": 100,
"step": 4,
"default": 40
},
{
"type": "range",
"id": "padding_bottom",
"label": "Bottom padding",
"min": 0,
"max": 100,
"step": 4,
"default": 40
}
],
"blocks": [
{
"type": "benefit",
"name": "Benefit",
"settings": [
{
"type": "image_picker",
"id": "icon",
"label": "Icon"
},
{
"type": "text",
"id": "title",
"label": "Title",
"default": "Fast Shipping"
},
{
"type": "richtext",
"id": "description",
"label": "Description"
}
]
}
],
"presets": [
{
"name": "Featured Benefits",
"blocks": [
{ "type": "benefit" },
{ "type": "benefit" },
{ "type": "benefit" }
]
}
]
}
{% endschema %}
Notice the key patterns: every text field checks for blank before rendering, blocks use shopify_attributes for theme editor targeting, images use the image_url filter with explicit width, and the schema includes a preset so merchants can add the section from the editor. For more on safe theme modification patterns, see my theme customization guide.
Advanced Metafield Integration
Metafields extend Shopify’s data model beyond what’s available by default. They’re how you add custom data — specifications, care instructions, size charts, ingredient lists — without apps.
Available Metafield Types in 2026
Shopify supports a wide range of metafield types: single-line text, multi-line text, rich text (HTML), integer, decimal, JSON, true/false, date, URL, color, file reference (images, videos), product reference, collection reference, page reference, and more.
The most powerful types for development:
- JSON — store structured data like size charts, nutrition facts, or complex specifications
- Rich text — merchant-editable HTML content without touching templates
- File reference — attach images or documents to products, variants, or collections
- Product reference — create explicit product relationships (alternatives, accessories, bundles)
Accessing Metafields in Liquid
{% comment %} Standard metafield access {% endcomment %}
{{ product.metafields.custom.care_instructions }}
{{ product.metafields.custom.size_chart.value }}
{% comment %} Type-specific rendering {% endcomment %}
{% if product.metafields.custom.size_chart != blank %}
<div class="size-chart">
{% assign chart = product.metafields.custom.size_chart.value %}
<table>
<thead>
<tr>
{% for header in chart.headers %}
<th>{{ header }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for row in chart.rows %}
<tr>
{% for cell in row %}
<td>{{ cell }}</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% comment %} File reference metafield (image) {% endcomment %}
{% if product.metafields.custom.lifestyle_image != blank %}
{{ product.metafields.custom.lifestyle_image.value | image_url: width: 800 | image_tag: loading: 'lazy', alt: product.title }}
{% endif %}
The rule I follow: always use metafield definitions (created in Shopify admin under Settings > Custom data) rather than creating metafields on the fly via API. Definitions provide type validation, merchant-friendly names, and visibility in the admin product editor.
Performance Optimization Techniques
This is the section that ties directly to my CRO work. Every second of page load time costs you conversions. On a recent project with WD Electronics, we identified 9.3-second LCP caused largely by unoptimized Liquid templates and image loading.
Image Loading Done Right
The loading="lazy" HTML attribute is only half the story. Proper Shopify image optimization requires the image_url filter with explicit dimensions:
{% comment %} BAD — no dimensions, browser can't reserve space (causes CLS) {% endcomment %}
<img src="{{ product.featured_image | img_url: 'master' }}" alt="{{ product.title }}">
{% comment %} GOOD — explicit width, lazy loading, srcset for responsive {% endcomment %}
{{ product.featured_image | image_url: width: 600 | image_tag:
loading: 'lazy',
widths: '200,400,600,800',
alt: product.title,
class: 'product-image'
}}
{% comment %} ABOVE THE FOLD — eager load, preload hint {% endcomment %}
{{ product.featured_image | image_url: width: 800 | image_tag:
loading: 'eager',
fetchpriority: 'high',
widths: '400,600,800,1000',
alt: product.title
}}
For mobile optimization strategies tied to image performance, see my mobile CRO guide.
Assign vs Capture for Reducing Render Time
Every Liquid operation has a cost. The difference between assign and capture matters when you’re building strings in loops:
{% comment %} SLOW — string concatenation in a loop creates new strings each iteration {% endcomment %}
{% assign output = '' %}
{% for product in collection.products %}
{% assign output = output | append: '<li>' | append: product.title | append: '</li>' %}
{% endfor %}
{% comment %} FAST — capture builds the string in one pass {% endcomment %}
{% capture output %}
{% for product in collection.products %}
<li>{{ product.title }}</li>
{% endfor %}
{% endcapture %}
{{ output }}
Avoiding Nested Loop Disasters
Nested {% for %} loops are the #1 Liquid performance killer. I wrote an entire deep-dive on this: Shopify Liquid Loop Optimization. The short version:
{% comment %} TERRIBLE — O(n*m) complexity, 50 products * 20 tags = 1000 iterations {% endcomment %}
{% for product in collection.products %}
{% for tag in product.tags %}
{% if tag == 'featured' %}
{% render 'product-card', product: product %}
{% endif %}
{% endfor %}
{% endfor %}
{% comment %} BETTER — filter first, iterate once {% endcomment %}
{% assign featured = collection.products | where: 'tags', 'featured' %}
{% for product in featured %}
{% render 'product-card', product: product %}
{% endfor %}
On a Factory Direct Blinds project, optimizing nested loops in the collection template dropped mobile PageSpeed from 38 to 81 — a 113% improvement.
Use render, Not include
The {% include %} tag is deprecated. Always use {% render %}:
{% comment %} DEPRECATED — include shares the parent scope (slow, unpredictable) {% endcomment %}
{% include 'product-card' %}
{% comment %} CORRECT — render creates an isolated scope (faster, predictable) {% endcomment %}
{% render 'product-card', product: product, show_vendor: true %}
{% render %} is faster because it creates an isolated scope — the snippet can’t accidentally read or modify variables from the parent template. It also enables lazy rendering, where Shopify can optimize execution.
Whitespace Control
Every newline and space in your Liquid output adds to the HTML payload. Use the {%- and -%} tags to strip whitespace:
{%- if product.available -%}
{%- for variant in product.variants -%}
{%- if variant.available -%}
<option value="{{ variant.id }}">{{ variant.title }}</option>
{%- endif -%}
{%- endfor -%}
{%- endif -%}
This is especially impactful inside loops that iterate over large collections. On a 100-product collection page, whitespace control can reduce HTML size by 10-20KB.
Custom Product Templates Without Apps
One of the highest-value moves in Shopify development is replacing app functionality with custom Liquid. Every app you remove means fewer HTTP requests, less JavaScript, and faster page loads — which directly impacts conversion rates.
Alternate Product Templates in Online Store 2.0
Create different templates for different product types:
{% comment %} templates/product.bundle.json {% endcomment %}
{
"sections": {
"main": {
"type": "main-product-bundle",
"settings": {}
},
"recommendations": {
"type": "product-recommendations",
"settings": {}
}
},
"order": ["main", "recommendations"]
}
Merchants assign templates to products in the admin. No app needed — just different section arrangements for different product types.
Building a Tabbed Product Description
This replaces tab apps that typically cost $5-15/month and add 50-100KB of JavaScript:
{% comment %} snippets/product-tabs.liquid {% endcomment %}
<div class="product-tabs" x-data="{ active: 'description' }">
<div class="product-tabs__nav" role="tablist">
<button role="tab"
class="product-tabs__tab"
:class="{ 'is-active': active === 'description' }"
@click="active = 'description'"
aria-selected="true">
Description
</button>
{% if product.metafields.custom.specifications != blank %}
<button role="tab"
class="product-tabs__tab"
:class="{ 'is-active': active === 'specs' }"
@click="active = 'specs'">
Specifications
</button>
{% endif %}
{% if product.metafields.custom.shipping_info != blank %}
<button role="tab"
class="product-tabs__tab"
:class="{ 'is-active': active === 'shipping' }"
@click="active = 'shipping'">
Shipping
</button>
{% endif %}
</div>
<div class="product-tabs__content">
<div x-show="active === 'description'" role="tabpanel">
{{ product.description }}
</div>
{% if product.metafields.custom.specifications != blank %}
<div x-show="active === 'specs'" role="tabpanel">
{{ product.metafields.custom.specifications | metafield_tag }}
</div>
{% endif %}
{% if product.metafields.custom.shipping_info != blank %}
<div x-show="active === 'shipping'" role="tabpanel">
{{ product.metafields.custom.shipping_info | metafield_tag }}
</div>
{% endif %}
</div>
</div>
This example uses Alpine.js for lightweight interactivity (3KB vs 30-80KB for typical app JS). The tabs are driven by metafields, so merchants control the content from the product admin.
Cart Modifications and AJAX Cart Implementation
Cart customization is one of the highest-leverage areas for AOV growth. For more on checkout-specific optimizations, see my checkout optimization guide.
Shopify Cart API Basics
Shopify’s AJAX API lets you modify the cart without page reloads:
// Add to cart
fetch('/cart/add.js', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: variantId,
quantity: 1,
properties: {
'_gift_message': 'Happy Birthday!'
}
})
}).then(r => r.json()).then(data => {
updateCartDrawer();
});
// Get current cart
fetch('/cart.js').then(r => r.json()).then(cart => {
console.log(cart.item_count, cart.total_price);
});
// Update quantities
fetch('/cart/change.js', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: lineItemKey, quantity: 2 })
}).then(r => r.json()).then(data => {
updateCartDrawer();
});
Free Shipping Progress Bar
This is a conversion booster I add to nearly every cart I work on. Pure Liquid + minimal JS, no app:
{% comment %} snippets/free-shipping-bar.liquid {% endcomment %}
{%- assign threshold = 7500 -%}{% comment %} $75.00 in cents {% endcomment %}
{%- assign remaining = threshold | minus: cart.total_price -%}
{%- assign progress = cart.total_price | times: 100 | divided_by: threshold -%}
{%- if progress > 100 %}{% assign progress = 100 %}{% endif -%}
<div class="shipping-bar">
<div class="shipping-bar__track">
<div class="shipping-bar__fill" style="width: {{ progress }}%"></div>
</div>
{%- if remaining > 0 -%}
<p class="shipping-bar__text">
Add <strong>{{ remaining | money }}</strong> more for free shipping
</p>
{%- else -%}
<p class="shipping-bar__text shipping-bar__text--success">
You've unlocked free shipping!
</p>
{%- endif -%}
</div>
Line Item Properties
Line item properties let customers add custom data to cart items — gift messages, personalization text, custom measurements:
<form action="/cart/add" method="post">
<input type="hidden" name="id" value="{{ product.selected_or_first_available_variant.id }}">
<label for="engraving">Custom engraving (optional)</label>
<input type="text" id="engraving" name="properties[Engraving]" maxlength="20" placeholder="Max 20 characters">
<label>
<input type="checkbox" name="properties[Gift Wrap]" value="Yes">
Add gift wrapping (+$5)
</label>
<button type="submit">Add to Cart</button>
</form>
Properties with names starting with _ (underscore) are hidden from the customer but visible in the admin — useful for tracking internal metadata.
Shopify Liquid in 2026: What’s New
Shopify has made several significant improvements to Liquid and theme development over the past year.
Section Rendering API improvements allow sections to be rendered independently via AJAX, enabling faster page transitions without full reloads. This is the foundation for app-like experiences within Liquid themes.
New Liquid filters added in 2025-2026 include enhanced image handling with image_tag supporting fetchpriority and sizes attributes, improved metafield rendering with metafield_tag, and better date/time formatting options.
Theme Check 2.0 is now built into the Shopify CLI and catches performance issues, accessibility problems, and deprecated pattern usage before you deploy. I run it on every commit.
AI-assisted development has become part of my workflow. I use AI tools to generate boilerplate section schemas, refactor complex Liquid logic, and write initial implementations that I then review and optimize. The key is treating AI output as a first draft — always review for Shopify-specific patterns, edge cases, and performance implications.
The overall direction is clear: Shopify is making Liquid themes more capable, not less. Online Store 2.0’s section architecture continues to evolve, and the gap between what Liquid themes can do and what headless can do narrows with every edition.
Common Liquid Mistakes I See on Every Audit
After auditing 100+ Shopify stores, these are the mistakes that appear on virtually every one:
1. Hardcoded content that should be in section settings. Product page trust badges, announcement bar text, footer payment icons — all hardcoded in Liquid files. If a merchant wants to change “Free shipping over $50” to “$75”, they need a developer. Put it in the schema.
2. Rendering product descriptions without sanitization. Raw {{ product.description }} can contain broken HTML from copy-pasting from Word or Google Docs. Always consider {{ product.description | strip_html }} for plain text contexts, or at minimum wrap it in a container that handles overflow.
3. Not handling empty states. Collections with zero products, products with no reviews, cart with no items — these all need graceful fallbacks:
{%- if collection.products.size > 0 -%}
{% for product in collection.products %}
{% render 'product-card', product: product %}
{% endfor %}
{%- else -%}
<p>No products found. <a href="/collections/all">Browse all products</a>.</p>
{%- endif -%}
4. Missing image alt tags. Every <img> rendered by Liquid should have an alt attribute. Use alt: product.title at minimum, or alt: image.alt | default: product.title to prefer Shopify’s image alt text field.
5. Loading all JavaScript and CSS globally. Theme CSS and JS files load on every page. If a section’s CSS and JS is only needed on product pages, conditionally load it:
{%- if template contains 'product' -%}
<link rel="stylesheet" href="{{ 'product-configurator.css' | asset_url }}" media="print" onload="this.media='all'">
<script src="{{ 'product-configurator.js' | asset_url }}" defer></script>
{%- endif -%}
6. Not using whitespace control. As covered in the performance section, {%- and -%} tags prevent unnecessary whitespace from bloating your HTML output.
7. Skipping pagination on collection pages. Displaying all products on a single page destroys load time. Always paginate:
{%- paginate collection.products by 24 -%}
{% for product in collection.products %}
{% render 'product-card', product: product %}
{% endfor %}
{% if paginate.pages > 1 %}
{% render 'pagination', paginate: paginate %}
{% endif %}
{%- endpaginate -%}
Need a Liquid Developer Who Actually Understands CRO?
Most Liquid developers write code that works. I write code that converts. The difference is 12 years of seeing what actually drives revenue on Shopify stores — and knowing which 20% of development work produces 80% of the conversion impact.
Whether you need a full theme build, app replacement, performance optimization, or a CRO-informed code audit, here’s how to get started.
Book a free 30-minute strategy call — I’ll review your theme code live and identify the top opportunities.