03 · Liquid fundamentals
Sintaxis, filters, tags, objects — y puentes desde Angular.
Liquid es un lenguaje de templates server-side con dos construcciones: {{ expression }} para imprimir un valor y {% tag %} para controlar el flujo. Es deliberadamente limitado: no puede ejecutar código arbitrario, no tiene acceso a APIs externas, y no tiene acceso al DOM. Esa restricción es una feature, no un bug.
Qué es y por qué existe
Shopify creó Liquid en 2006 para resolver un problema de confianza. Un comerciante que instala un theme necesita poder confiar en que ese theme no puede extraer sus datos, ejecutar código malicioso, ni comprometer su tienda. Con un lenguaje de templates de propósito general como JavaScript o Python corriendo en el servidor, esa garantía sería imposible de dar.
Liquid es un sandbox: un conjunto cerrado de operaciones de template que solo puede leer los datos que Shopify le provee explícitamente para el contexto actual. En una página de producto, Liquid puede leer los datos de ese producto y del carrito actual. No puede hacer consultas a la base de datos, no puede hacer peticiones HTTP, y no puede modificar el estado de la tienda. Todo lo que imprime en pantalla viene de los objetos que Shopify inyecta en el contexto del render.
El lenguaje fue open-source desde el principio y hoy se usa fuera de Shopify — Jekyll (el generador de sitios estáticos de GitHub), Liquid.js (una implementación en Node), y varias plataformas de CMS lo usan o lo adaptaron. Pero la versión que importa aquí es la de Shopify, que tiene sus propios objetos, filtros y extensiones.
Las dos familias de sintaxis
Output tags: imprimen el valor de una expresión.
{{ product.title }}
{{ product.price | money }}
{{ 'Brew Atlas' | upcase }}Block tags: controlan el flujo, asignan variables, o ejecutan funciones que no producen output directamente.
{% assign price = product.price | money %}
{% if product.available %}
<p>En stock</p>
{% endif %}
{% for variant in product.variants %}
<option value="{{ variant.id }}">{{ variant.title }}</option>
{% endfor %}La distinción es importante: {{ }} siempre produce output al HTML resultante. {% %} ejecuta lógica pero no produce output por sí solo (excepto el HTML que escribas dentro de él).
Filters
Los filters son transformaciones que se aplican al valor de una expresión usando el operador | (pipe). Funcionan igual que los pipes de Unix: la salida de la izquierda se convierte en la entrada del filter de la derecha, y puedes encadenarlos.
{{ product.title | upcase }}
{{ product.title | downcase | replace: 'atlas', 'ATLAS' }}
{{ product.price | money }}
{{ product.price | money_without_currency }}
{{ product.featured_image | image_url: width: 800 }}
{{ product.description | truncatewords: 30 }}
{{ 'now' | date: '%Y-%m-%d' }}Los filters son funciones predefinidas — no puedes crear filtros custom en Liquid. Los filtros disponibles dependen del contexto: algunos solo están disponibles en ciertas plantillas (por ejemplo, money solo existe cuando Liquid corre dentro de un contexto de tienda, no en Liquid.js puro).
Categorías de filters relevantes para desarrollo de themes:
- String:
upcase,downcase,strip,replace,remove,truncate,truncatewords,escape,newline_to_br,url_encode - Array:
first,last,join,sort,sort_natural,where,uniq,reverse,size - Math:
plus,minus,times,divided_by,modulo,ceil,floor,round,abs - HTML/media:
image_url,image_tag,link_to,asset_url,stylesheet_tag,script_tag - Money:
money,money_with_currency,money_without_currency,money_without_trailing_zeros - Date:
date(formato strftime) - URL/path:
url_for_type,url_for_vendor,within
Tags de control de flujo
{%- if product.available -%}
<p>Disponible</p>
{%- elsif product.variants.size > 0 -%}
<p>Bajo pedido</p>
{%- else -%}
<p>Agotado</p>
{%- endif -%}
{%- unless product.available -%}
<p class="badge badge--sold-out">Agotado</p>
{%- endunless -%}
{%- case product.type -%}
{%- when 'Suscripción' -%}
<span>Plan mensual</span>
{%- when 'One-time' -%}
<span>Compra única</span>
{%- else -%}
<span>{{ product.type }}</span>
{%- endcase -%}unless es if not — existe para mejorar legibilidad. case/when es el equivalente a un switch. Todas las estructuras de control tienen su end correspondiente.
Tags de iteración
{%- for product in collection.products -%}
<article class="card">{{ product.title }}</article>
{%- else -%}
<p>No hay productos en esta colección.</p>
{%- endfor -%}
{%- for variant in product.variants limit: 3 offset: 1 -%}
<li>{{ variant.title }} — {{ variant.price | money }}</li>
{%- endfor -%}
{%- for i in (1..5) -%}
<span>{{ i }}</span>
{%- endfor -%}Dentro de un for, Liquid inyecta automáticamente el objeto forloop con forloop.index (1-based), forloop.index0 (0-based), forloop.first, forloop.last, forloop.length. El bloque else dentro de un for se ejecuta si el array está vacío.
Tags de asignación y captura
{%- assign title = product.title | downcase -%}
{%- assign is_on_sale = false -%}
{%- if product.compare_at_price > product.price -%}
{%- assign is_on_sale = true -%}
{%- endif -%}
{%- capture product_meta -%}
<span class="meta">{{ product.vendor }}</span>
<span class="meta">{{ product.type }}</span>
{%- endcapture -%}
{{ product_meta }}assign crea una variable con un valor simple. capture captura el output de un bloque de template en una variable string. Ambas son variables locales al scope actual.
Render vs include
render es el tag moderno para incluir snippets. include está deprecado y no deberías usarlo en código nuevo.
La diferencia crítica es el scope:
{%- comment -%}
Con include (deprecated): el snippet puede ver TODAS las variables del scope padre
{%- endcomment -%}
{%- include 'product-card' -%}
{%- comment -%}
Con render (correcto): el snippet solo ve las variables que le pasás explícitamente
{%- endcomment -%}
{%- render 'product-card', product: product, show_vendor: true -%}Con render, el snippet está completamente aislado del scope del llamador. No puede acceder a variables definidas antes del render a menos que se las pases como parámetros. Esta es una diferencia fundamental con los componentes de Angular o React, donde los hijos tienen acceso a providers del padre — en Liquid, el aislamiento es estricto y explícito.
Objetos de contexto
Los objetos disponibles en Liquid dependen del tipo de template que se está renderizando. En una página de producto tienes product; en una colección tienes collection y collection.products; en el carrito tienes cart. Los objetos siempre disponibles en todo contexto incluyen shop, customer, cart, y settings.
Objetos principales:
| Objeto | Disponible en | Contiene |
|---|---|---|
product | Templates de producto | título, variants, imágenes, metafields, precio, URL |
collection | Templates de colección | título, productos, handle, descripción |
cart | Todo | items, total, item_count, attributes |
customer | Todo (si autenticado) | nombre, email, pedidos, metafields |
shop | Todo | nombre, moneda, metafields, configuración |
settings | Todo | configuración del theme (settings_schema.json) |
section | Dentro de una section | ID, settings, blocks |
block | Dentro de un block | ID, settings, shopify_attributes |
Control de espacios en blanco
Por defecto, Liquid preserva los espacios y saltos de línea alrededor de los tags. Eso puede generar HTML con muchos espacios vacíos. Para controlarlo, usa los modificadores -% y {%-:
{%- for item in cart.items -%}
<li>{{ item.title }}</li>
{%- endfor -%}El - al inicio o al final del tag elimina el whitespace (espacios, tabs y saltos de línea) en ese lado. Usarlo en ambos lados ({%- ... -%}) produce el HTML más compacto. Para producción, actívalo en todos los tags de control de flujo — el HTML generado es más limpio y más fácil de depurar.
Liquid tag block
Para bloques largos con varios tags de Liquid sin output entre ellos, usa liquid:
{%- liquid
assign price = product.price | money
assign is_available = product.available
assign vendor = product.vendor | downcase
if is_available
assign status_class = 'available'
else
assign status_class = 'unavailable'
endif
-%}Dentro de liquid, cada línea es un tag sin necesidad de {% %}. Es más legible que muchos tags individuales cuando tienes lógica compleja.
Puentes mentales
Liquid es a Shopify lo que Jinja es a Django, o Handlebars a Ember. Pero la comparación más útil es con Angular templates: en Angular, *ngIf y *ngFor son directivas que modifican el DOM; en Liquid, if y for son tags que controlan qué HTML se imprime en el servidor. No hay reactividad, no hay bindings — el resultado es HTML estático.
La diferencia más importante respecto a componentes de Angular o React es el scope. En Angular, un componente hijo puede inyectar servicios del padre o recibir inputs. En Liquid, un snippet incluido con render está completamente aislado: no puede leer variables del scope padre a menos que se las pases explícitamente como parámetros. Piénsalo como un componente sin providers, sin context, sin nada compartido implícitamente.
Los filters de Liquid son análogos a los pipes de Angular — transformaciones sin estado que se aplican en cadena. La diferencia: en Angular puedes crear pipes custom; en Liquid, los filters son un conjunto cerrado definido por Shopify.
Estado 2026
El lenguaje Liquid en sí cambia poco entre versiones de Shopify. Lo que cambia con frecuencia son los objetos disponibles y sus propiedades (cuando Shopify agrega features nuevas) y los filtros (especialmente los de media e imágenes).
Cambios relevantes en 2025-2026:
image_urlcon parámetros de crop reemplazó el antiguo pattern deimg_url. Si ves| img_url:en themes viejos, está deprecado — usa| image_url: width: X.- El objeto
sectionahora exponesection.indexcuando la section aparece varias veces en la misma página (useful para IDs únicos de elementos HTML). - El objeto
predictive_searchfue agregado para autocompletado de búsqueda sin JavaScript. - Liquid no tiene async: todos los datos del contexto se resuelven antes de que empiece el render. No hay
awaitni promises en Liquid.
Sintaxis y anatomía
Un snippet completo real que muestra la mayoría de los patrones. Crealo en ~/proyectos/shopify/brew-atlas/brew-atlas-theme/snippets/product-card.liquid:
{%- comment -%} theme/snippets/product-card.liquid {%- endcomment -%}
{%- liquid
assign price = product.price | money
assign sold_out = false
unless product.available
assign sold_out = true
endunless
if product.compare_at_price > product.price
assign on_sale = true
assign savings = product.compare_at_price | minus: product.price | money
else
assign on_sale = false
endif
-%}
<article class="card {%- if sold_out %} card--sold-out{%- endif -%} {%- if on_sale %} card--on-sale{%- endif -%}">
<a href="{{ product.url }}" class="card__link">
{%- if product.featured_image -%}
<div class="card__media">
<img
src="{{ product.featured_image | image_url: width: 600 }}"
srcset="{{ product.featured_image | image_url: width: 300 }} 300w,
{{ product.featured_image | image_url: width: 600 }} 600w,
{{ product.featured_image | image_url: width: 900 }} 900w"
sizes="(min-width: 768px) 300px, 100vw"
width="600"
height="600"
alt="{{ product.featured_image.alt | escape }}"
loading="lazy"
/>
</div>
{%- endif -%}
<div class="card__info">
{%- if show_vendor -%}
<p class="card__vendor">{{ product.vendor }}</p>
{%- endif -%}
<h3 class="card__title">{{ product.title }}</h3>
<div class="card__pricing">
<span class="price {%- if on_sale %} price--sale{%- endif -%}">
{{ price }}
</span>
{%- if on_sale -%}
<span class="price price--compare">
{{ product.compare_at_price | money }}
</span>
<span class="badge badge--sale">Ahorra {{ savings }}</span>
{%- endif -%}
{%- if sold_out -%}
<span class="badge badge--sold-out">Agotado</span>
{%- endif -%}
</div>
</div>
</a>
</article>Este es exactamente el snippet que vive en brew-atlas/theme/snippets/product-card.liquid. Observa:
- La variable
show_vendorllega como parámetro delrender— no existe en el scope del snippet por defecto. - Se usan variables intermedias (
sold_out,on_sale,savings) para mantener la lógica separada del template. - El whitespace control (
{%- -%}) se usa consistentemente. image_urlcon parámetros desrcset— el pattern moderno para imágenes responsive.- El
altdel la imagen usa| escapepara prevenir XSS si el texto del alt contiene caracteres especiales.
Para usar este snippet desde una section (esto va dentro de la section que corresponda, no es un archivo separado):
{%- for product in collection.products -%}
{%- render 'product-card', product: product, show_vendor: true -%}
{%- endfor -%}Proyecto · Brew Atlas
El primer archivo real del theme de Brew Atlas es el snippet product-card.liquid. Es el componente de presentación que se va a usar en la página de colección, en la sección de “Productos relacionados” del PDP, y eventualmente en el storefront headless con Hydrogen (adaptado a JSX).
Qué vas a crear en esta sección:
~/proyectos/shopify/brew-atlas/brew-atlas-theme/snippets/product-card.liquid— el snippet de tarjeta de producto (el código completo está en la sección “Sintaxis y anatomía” arriba)
La copia de referencia del tutorial vive en brew-atlas/theme/snippets/product-card.liquid (en este repo). Tu versión va en el scaffold local bajo ~/proyectos/shopify/brew-atlas/brew-atlas-theme/snippets/.
Comandos a ejecutar (orden): ninguno — esta sección solo crea un archivo Liquid. No hay comandos para correr.
Empezamos con este snippet porque es representativo: tiene lógica condicional, filters de imagen, parámetros explícitos, y whitespace control. Si lo entiendes completamente, el 80% de los snippets que encuentres en themes reales son variaciones de este patrón.
Errores comunes
Usar include en lugar de render es el error más común en código nuevo escrito por desarrolladores que aprendieron Liquid con themes viejos. include comparte el scope completo del llamador con el snippet — cualquier variable que hayas definido antes es accesible dentro del snippet sin que te lo esperes. Eso crea acoplamiento implícito difícil de detectar. include está deprecado. Siempre usa render.
render no puede llamar a render de forma recursiva de manera predeterminada. Si necesitas recursión (por ejemplo, para renderizar una navegación anidada), Liquid tiene el tag render con el modificador for que itera sobre un array — úsalo en lugar de recursión explícita.
Los tipos en Liquid son dinámicos: assign x = 5 crea un número, assign x = '5' crea un string, y en operaciones de comparación el tipo importa. {{ 5 | plus: 0 }} es 5, pero {{ '5' | plus: 0 }} también es 5 porque Liquid hace coerción de tipos en filtros numéricos. Puede ser confuso al debuggear.
Para depurar el valor de una variable en Liquid, usa {{ variable | json }} — imprime la representación JSON completa del objeto, incluyendo todas sus propiedades. Es el equivalente de console.log(JSON.stringify(obj)). Recuerda quitarlo en producción.
Checklist senior
- Puedes leer un snippet de Liquid desconocido y entender qué HTML va a producir
- Sabes la diferencia entre
rendereincludey por qué solo usaríasrender - Puedes encadenar filters y saber el tipo que devuelve cada uno
- Entiendes por qué las variables de un
renderestán aisladas del scope padre - Sabes qué objetos están disponibles en el contexto de una página de producto vs una página de colección
- Puedes escribir un snippet con lógica condicional, iteración y whitespace control correcto
- Sabes cómo usar
| jsonpara depurar objetos en Liquid - Conoces la diferencia entre
{{ product.price }}(número en centavos) y{{ product.price | money }}(string formateado)
Quiz · ¿Lo tenés claro?
-
1. ¿Por qué en Liquid moderno se usa `{% render 'card' %}` y no `{% include 'card' %}`?
La diferencia es el scope. `render` crea un scope aislado — solo ve lo que le pasas como parámetro. `include` comparte el scope del padre (variables globales accidentales, bugs de mutación). Shopify deprecó `include` en favor de `render`.
-
2. Necesitas el precio de un producto formateado como string con símbolo de moneda del comprador. ¿Qué expresión usas?
`product.price` es un entero en la unidad más pequeña (ej. centavos). El filter `| money` lo formatea según el locale del shop (símbolo, separadores decimales, posición). `currency` no existe en Liquid estándar.
-
3. ¿Cuál objeto NO está disponible en el contexto de `templates/product.json`?
`order` solo existe en `templates/customers/order.json` y en las notificaciones de email. En una PDP tienes product, collection (si entraste desde una), shop, cart, customer (opcional) — pero no order.
-
4. Quieres depurar el objeto `product` para ver todos sus campos sin leer la doc. ¿Qué haces?
El filter `| json` serializa cualquier objeto Liquid. Envolverlo en `<pre>` en la vista da un dump legible. No existen `inspect` ni `{% debug %}`. `window.product` no existe porque Liquid es server-side.
-
5. Vienes de Angular y quieres componentes. ¿Cuál es el paralelo más cercano en Liquid?
Los pipes de Angular (`| uppercase`) se mapean a filters de Liquid (`| upcase`). Un snippet renderizado con `render 'card', product: p` es un componente puro: recibe props, renderiza HTML, scope aislado. No hay reactividad: Liquid es server-side y one-shot.
Siguiente
Con Liquid en la cabeza, la siguiente sección entra en la anatomía completa de un theme de Online Store 2.0: directorios, JSON templates, sections everywhere, y la diferencia entre OS 1.0 (templates Liquid hardcodeados) y OS 2.0 (composición declarativa con JSON). Es el contexto que necesitas para entender dónde van a vivir todos los archivos Liquid que escribas.