Shopify

Shopify Liquid: Complete Guide for Frontend Developers

A practical guide to Shopify's Liquid templating language — objects, tags, filters, and the patterns you'll actually use.

Tayyab Roy8 min read
Share:

If you’re coming to Shopify from React or Vue, Liquid feels like a step backward at first — no components in the framework sense, no real state management, just a server-side templating language that spits out HTML before anything hits the browser. But once you understand its object model and a handful of tags and filters, it’s genuinely productive for what it’s built for: rendering storefront HTML fast, on Shopify’s servers, with zero client-side JavaScript required just to show a product page. I’ve built and maintained several custom Shopify themes, and Liquid ends up being less of an obstacle and more of a well-defined contract once you stop fighting it.

The Object Model

Liquid gives you a set of global and contextual objects depending on which template is rendering. On a product page, you get product. On a collection page, you get collection. Everywhere, you get cart, shop, and customer (if logged in). These are just dot-accessible data structures:

<h1>{{ product.title }}</h1>
<p>{{ product.description }}</p>
<span>{{ product.price | money }}</span>

{% for image in product.images %}
  <img src="{{ image | img_url: '400x' }}" alt="{{ product.title }}">
{% endfor %}

The product object carries variants, images, options, metafields, and more. collection.products gives you the paginated product list for a collection template. cart.items gives you line items with quantities and prices. Section settings come through section.settings, and block-level config comes through block.settings inside a {% for block in section.blocks %} loop. Knowing which object is in scope for which template file is 80% of knowing Liquid — the syntax itself is small.

A common beginner mistake is assuming every object is available everywhere. product doesn’t exist on a collection template unless you’re inside a {% for product in collection.products %} loop. If you reference an undefined object, Liquid doesn’t throw — it silently renders nothing, which makes debugging “why is this blank” a regular exercise. Use {{ product.title | json }} temporarily when you need to see what’s actually in an object.

Tags: Logic and Control Flow

Tags are wrapped in {% %} and don’t output anything themselves. The ones you’ll use constantly:

{% if product.available %}
  <button>Add to Cart</button>
{% else %}
  <button disabled>Sold Out</button>
{% endif %}

{% for variant in product.variants %}
  <option value="{{ variant.id }}">{{ variant.title }}</option>
{% endfor %}

{% assign featured_image = product.featured_image | default: product.images.first %}

{% assign %} and {% capture %} let you build intermediate values — useful for things like building a class string conditionally instead of nesting ternary-style if tags inline.

render vs. the deprecated include

This is the single most common thing I see get flagged in theme reviews: using {% include %} instead of {% render %}.

<!-- Don't do this -->
{% include 'product-card', product: product %}

<!-- Do this -->
{% render 'product-card', product: product %}

include is deprecated and Shopify explicitly recommends against it. The difference isn’t cosmetic. include shares the parent template’s entire variable scope — the snippet can read and mutate any variable in the calling context, which makes snippets fragile and hard to reason about in isolation. render creates an isolated scope: the snippet only sees the variables you explicitly pass in, plus global objects like shop and settings. This means a render-based snippet is portable — you can drop it into any template and know exactly what it depends on by reading its parameter list. It also performs slightly better because Liquid doesn’t need to carry the full parent scope into it. There’s no legitimate reason to reach for include in a new theme.

Filters: Transforming Output

Filters use the pipe | syntax and transform a value before it’s output. These are the ones you’ll type daily:

{{ product.price | money }}
{{ product.price | money_with_currency }}
{{ product.title | truncate: 50 }}
{{ product.description | strip_html | truncate: 160 }}
{{ image | img_url: '600x600', crop: 'center' }}
{{ image | image_url: width: 600 }}
{{ 'theme.js' | asset_url | script_tag }}
{{ product.created_at | date: '%B %d, %Y' }}

money formats according to the shop’s currency settings — never hand-format prices yourself, since you’ll get locale and currency formatting wrong in ways that only show up for international customers. img_url (legacy) and image_url (current) generate CDN URLs at a specific size rather than serving the original upload; this matters enough for page performance that it gets its own section in Shopify Performance Optimization. strip_html is essential before truncating rich-text fields — otherwise you risk cutting a string in the middle of an HTML tag and breaking the DOM.

Escaping output

Liquid does not auto-escape output by default the way some templating engines do. If you’re rendering anything that could contain user input or arbitrary text — customer-submitted content, metafields set by less-trusted staff accounts — escape it:

{{ customer_review.body | escape }}

Forgetting this is a real XSS vector in theme customization fields, not just a theoretical concern. Shopify’s escape filter HTML-encodes the string; use strip_html instead when you want plain text with tags removed entirely.

Sections and Blocks

Sections are Liquid files in sections/ that pair HTML/Liquid markup with a {% schema %} block defining what’s configurable in the theme editor. This is what makes Online Store 2.0 themes merchant-editable without touching code:

<div class="featured-collection" style="background: {{ section.settings.bg_color }}">
  <h2>{{ section.settings.heading }}</h2>
  <div class="product-grid">
    {% for block in section.blocks %}
      {% render 'product-card', product: all_products[block.settings.product] %}
    {% endfor %}
  </div>
</div>

{% schema %}
{
  "name": "Featured Collection",
  "settings": [
    {
      "type": "text",
      "id": "heading",
      "label": "Heading",
      "default": "Featured Products"
    },
    {
      "type": "color",
      "id": "bg_color",
      "label": "Background Color",
      "default": "#ffffff"
    }
  ],
  "blocks": [
    {
      "type": "product",
      "name": "Product",
      "settings": [
        {
          "type": "product",
          "id": "product",
          "label": "Product"
        }
      ]
    }
  ],
  "presets": [
    {
      "name": "Featured Collection"
    }
  ]
}
{% endschema %}

The settings array defines merchant-facing controls that show up in the theme editor sidebar; blocks define repeatable, reorderable sub-units within the section (a common pattern for testimonial carousels, FAQ accordions, or — as above — a hand-picked product grid). The presets array is what makes the section appear in the “Add section” picker at all; skip it and your section can only be added via theme code, not through the editor UI.

For the broader architectural picture — how sections, templates, and JSON configs fit together across a whole theme — see Shopify Theme Development: Architecture and Best Practices.

Snippets: Reuse Without Duplication

Snippets live in snippets/ and are meant to be small, focused, and parameterized:

<!-- snippets/price-display.liquid -->
<div class="price">
  {% if product.compare_at_price > product.price %}
    <s>{{ product.compare_at_price | money }}</s>
    <span class="price--sale">{{ product.price | money }}</span>
  {% else %}
    <span>{{ product.price | money }}</span>
  {% endif %}
</div>
{% render 'price-display', product: product %}

Treat snippets like pure functions: given the same inputs, they should render the same output, with no implicit dependency on whatever happens to be in scope where they’re called. This is the render-vs-include distinction playing out in practice — a well-written snippet declares its inputs explicitly and doesn’t assume anything about its caller.

Common Mistakes

Overusing Liquid logic for things that are really interactivity. Liquid runs once, server-side, at render time. If you find yourself trying to express “show this when the customer clicks that” in Liquid, you’re solving the wrong layer — that’s JavaScript’s job. Liquid should produce the initial HTML and data attributes; client-side behavior belongs in a script, ideally a small scoped module rather than global jQuery handlers.

Looping without limits. {% for product in collection.products %} without a limit can silently truncate at Liquid’s default pagination size or, worse, hammer through hundreds of objects on a large collection, which hurts render time and can hit Liquid’s object-access limits. Paginate explicitly with {% paginate collection.products by 24 %}.

Not checking for blank or empty. {% if product.description != blank %} is the correct way to check for empty rich-text fields — == "" doesn’t reliably catch Liquid’s blank/nil semantics the same way.

Hardcoding values that should be metafields or settings. If a merchant will ever want to change a value without a code deploy, it belongs in section.settings or a metafield, not hardcoded in the template.

FAQ

Can I use Liquid for API calls or async data? No — Liquid is a server-side rendering pass with no concept of async. For dynamic client-side data (cart updates, recommendations), you call Shopify’s Storefront or Admin APIs from JavaScript after the page loads. See REST API Integration Best Practices for patterns on that side.

Is Liquid slow? Liquid itself is fast — Shopify’s servers compile and cache templates. Perceived slowness almost always comes from oversized images, too many apps injecting scripts, or render-blocking third-party tags, not from Liquid execution itself.

Should I learn Liquid if I already know React? Yes, if you’re doing Shopify theme work. Headless Shopify setups (Hydrogen, custom storefronts) let you skip Liquid entirely, but the majority of production Shopify stores still run on Liquid-based Online Store 2.0 themes, and that’s where most freelance and agency work actually is.

#Shopify#Liquid#Theme Development

Written by

Tayyab Roy

Senior Frontend / Full-Stack Engineer

Senior Frontend & Full-Stack Engineer specializing in Angular, React, Next.js and enterprise eCommerce (Shopify, SAP Commerce / Hybris, Spartacus). I write about practical, production-tested frontend engineering.