Collections & Products
Almost every storefront template ultimately exists to display collections and products. Both are Liquid objects Shopify populates automatically based on the page being viewed, but products have an extra layer of complexity worth understanding: variants.
The collection object
On a collection page, collection holds that collection's data, and collection.products is the list you loop over:
<h1>{{ collection.title }}</h1>
<div class="grid">
{% for product in collection.products %}
{% render 'product-card', product: product %}
{% endfor %}
</div>
Products and variants
Every product has at least one variant — even a product with no real options (like size or color) still has a single default variant behind the scenes. Price, inventory, and SKU all actually belong to the variant, not the product itself:
{{ product.title }}
{{ product.selected_or_first_available_variant.price | money }}
{{ product.selected_or_first_available_variant.sku }}
product.price does work as a shortcut (it resolves to the current or first variant's price), but reaching for the variant explicitly matters the moment a product has real options.
Looping variants and options
<select name="id">
{% for variant in product.variants %}
<option value="{{ variant.id }}"{% unless variant.available %} disabled{% endunless %}>
{{ variant.title }} — {{ variant.price | money }}
</option>
{% endfor %}
</select>
That value="{{ variant.id }}" matters specifically: Shopify's cart and checkout system identifies exactly what to add by variant ID, never by product ID alone.
Filtering and sorting
Liquid isn't meant for heavy data manipulation, but simple filtering is common enough to have dedicated support — the where and sort filters work directly on arrays like collection.products:
{% assign on_sale = collection.products | where: "compare_at_price_max", 0 %}
{% assign cheapest_first = collection.products | sort: "price" %}
Pagination
A collection can hold thousands of products, so Liquid provides {% paginate %} to split a long list into pages instead of rendering everything at once:
{% paginate collection.products by 24 %}
{% for product in collection.products %}
{% render 'product-card', product: product %}
{% endfor %}
{{ paginate | default_pagination }}
{% endpaginate %}
default_pagination renders a ready-made Previous/Next control automatically, built from the pagination state paginate exposes (current page, total pages, and links to each).
Product recommendations
Shopify can generate "related products" suggestions automatically using its own recommendation model, available through a dedicated Liquid object rather than something you compute yourself:
{% if recommendations.performed and recommendations.products_count > 0 %}
{% for product in recommendations.products %}
{% render 'product-card', product: product %}
{% endfor %}
{% endif %}
product.images, but each variant can also have its own specific image (useful for color swatches) accessible as variant.image — falling back to the product's default image when a variant has none of its own.