Webshop
Build complete e-commerce experiences with products, collections, shopping cart, and checkout
Nimbu provides a complete e-commerce platform with products, variants, collections, shopping cart, checkout, and order management. Build custom storefronts with full control over the shopping experience.
Products
Products are accessible through the products drop and individual product pages via the product variable.
Product Properties
| Property | Description |
|---|---|
id | Unique product ID |
name | Product name |
description | HTML description |
sku | Stock keeping unit |
price | Base price (cents) |
on_sale | Boolean: product on sale |
on_sale_price | Sale price if on sale |
effective_price | Current price (sale or regular) |
price_for_current_customer | Customer-specific pricing |
url | Product page URL |
status | "active", "sold_out", "hidden" |
current_stock | Available inventory |
amount_in_cart | Quantity in customer's cart |
images | Array of product images |
variants | Array of product variants |
vendor | Brand/manufacturer name |
type | Product type/category |
weight | Weight in grams |
tax_scheme | Tax configuration |
seo_title, seo_description | SEO metadata |
| Custom attributes | Any custom fields defined |
Unset boolean custom fields return false instead of nil, as for channel entries. Use {% if product.my_flag %} rather than comparing with nil or ''.
Product Template
templates/shop/product.liquid:
<div class="product-page">
<div class="container">
{% breadcrumbs %}
<div class="product-main">
<!-- Product Images -->
<div class="product-images">
{% if product.images.size > 0 %}
<div class="main-image">
<img src="{{ product.images.first.url | filter, width: '800px' }}"
alt="{{ product.name }}"
id="main-product-image">
</div>
{% if product.images.size > 1 %}
<div class="image-thumbnails">
{% for image in product.images %}
<img src="{{ image.url | filter, width: '100px' }}"
alt="{{ product.name }}"
data-full="{{ image.url | filter, width: '800px' }}"
onclick="document.getElementById('main-product-image').src = this.dataset.full">
{% endfor %}
</div>
{% endif %}
{% endif %}
</div>
<!-- Product Info -->
<div class="product-info">
<h1>{{ product.name }}</h1>
{% if product.vendor %}
<p class="vendor">{{ product.vendor }}</p>
{% endif %}
<div class="product-price">
{% if product.on_sale %}
<span class="sale-price">{{ product.on_sale_price | money_with_currency }}</span>
<span class="original-price">{{ product.price | money_with_currency }}</span>
<span class="save-badge">
Save {{ product.price | minus: product.on_sale_price | money_with_currency }}
</span>
{% else %}
<span class="price">{{ product.price | money_with_currency }}</span>
{% endif %}
</div>
<div class="product-description wysiwyg">
{{ product.description }}
</div>
<!-- Variants & Add to Cart -->
{% if product.status != 'sold_out' %}
{{ product | add_to_cart,
btn_class: 'btn btn-primary btn-lg',
add_label: 'Add to Cart',
show_quantity_buttons: true
}}
{% else %}
<p class="sold-out-message">This product is currently sold out.</p>
{% endif %}
<!-- Product Details -->
<div class="product-details">
<h3>Details</h3>
<dl>
{% if product.sku %}
<dt>SKU</dt>
<dd>{{ product.sku }}</dd>
{% endif %}
{% if product.type %}
<dt>Type</dt>
<dd>{{ product.type }}</dd>
{% endif %}
<dt>Availability</dt>
<dd>
{% if product.current_stock > 0 %}
In stock ({{ product.current_stock }} available)
{% else %}
Out of stock
{% endif %}
</dd>
</dl>
</div>
</div>
</div>
<!-- Related Products -->
<div class="related-products">
<h2>You May Also Like</h2>
<div class="product-grid">
{% scope product_type_name == product.product_type_name %}
{% for related in products.random limit: 4 %}
{% unless related.id == product.id %}
{% include 'product-card', product: related %}
{% endunless %}
{% endfor %}
{% endscope %}
</div>
</div>
</div>
</div>Working with Variants
Products can have variants (sizes, colors, etc.):
<!-- Variants with add_to_cart helper -->
{{ product | add_to_cart,
btn_class: 'btn-primary',
hide_variants: false,
show_quantity_dropdown: true
}}
<!-- Manual variant selection -->
<select name="variant_id" class="variant-selector">
{% for variant in product.variants %}
<option value="{{ variant.id }}"
{% unless variant.stock > 0 %}disabled{% endunless %}>
{{ variant.name }}
{% if variant.stock > 0 %}
- {{ variant.price | money_with_currency }}
{% else %}
- Sold Out
{% endif %}
</option>
{% endfor %}
</select>Variant Properties
| Property | Description |
|---|---|
id | Variant ID |
name | Variant name (e.g., "Size 42") |
price | Variant-specific price |
on_sale_price | Sale price if applicable |
sku | Variant SKU |
stock | Available inventory |
weight | Weight in grams |
url | Product URL with variant selected |
Collections
Collections group related products together.
Collection Template
templates/shop/collection.liquid:
<div class="collection-page">
<div class="container">
<header class="collection-header">
<h1>{{ collection.name }}</h1>
{% if collection.description %}
<div class="collection-description">{{ collection.description }}</div>
{% endif %}
<p class="product-count">{{ collection.product_count }} products</p>
</header>
<!-- Filters -->
<div class="collection-filters">
<form action="{{ collection.url }}" method="get">
<!-- Filter by attribute -->
<select name="color">
<option value="">All Colors</option>
{% for color in products.attributes_in_use.color %}
<option value="{{ color }}" {% if params.color == color %}selected{% endif %}>
{{ color }}
</option>
{% endfor %}
</select>
<!-- Sort -->
<select name="sort">
<option value="">Default</option>
<option value="price_asc" {% if params.sort == 'price_asc' %}selected{% endif %}>
Price: Low to High
</option>
<option value="price_desc" {% if params.sort == 'price_desc' %}selected{% endif %}>
Price: High to Low
</option>
<option value="name_asc" {% if params.sort == 'name_asc' %}selected{% endif %}>
Name: A-Z
</option>
</select>
<button type="submit">Apply Filters</button>
</form>
</div>
<!-- Products Grid -->
<div class="products-grid">
{% paginate collection.products by 12 %}
{% for product in paginate.collection %}
{% include 'product-card', product: product %}
{% endfor %}
{{ paginate | default_pagination }}
{% endpaginate %}
</div>
</div>
</div>Collection Properties
| Property | Description |
|---|---|
name | Collection name |
description | HTML description |
slug | URL slug |
url | Collection page URL |
products | Products in collection |
product_count | Number of products |
current? | Boolean: viewing this collection |
Filtering Products
Use {% scope %} to filter products:
<!-- Filter by collection -->
{% scope collection_slugs contains 'summer-2024' %}
{% for product in products.all %}
{% include 'product-card', product: product %}
{% endfor %}
{% endscope %}
<!-- Multiple conditions -->
{% scope vendor == 'Nike' and price > 5000 and on_sale == true %}
{% for product in products.all %}
{% include 'product-card', product: product %}
{% endfor %}
{% endscope %}
<!-- Filter by custom attribute -->
{% scope color == params.color %}
{% for product in products.all %}
{% include 'product-card', product: product %}
{% endfor %}
{% endscope %}See Channels for complete scope documentation.
Shopping Cart
Access the cart through the cart drop (available when session exists).
Cart Properties
| Property | Description |
|---|---|
items | Array of line items |
items_count | Total items |
total_price | Total before shipping/tax |
total_weight | Total weight |
subtotal_price | Subtotal |
shipping_price | Shipping cost |
taxes | Tax amount |
grand_total | Final total with shipping/taxes |
Add to Cart
Use the add_to_cart filter:
<!-- Basic -->
{{ product | add_to_cart }}
<!-- With options -->
{{ product | add_to_cart,
btn_class: 'btn btn-primary',
add_label: 'Add to Cart',
default_quantity: 1,
hide_variants: false,
show_quantity_dropdown: true,
show_quantity_buttons: true
}}Cart Page
templates/cart.liquid:
<div class="cart-page">
<div class="container">
<h1>Shopping Cart</h1>
{% if cart.items.size > 0 %}
<div class="cart-items">
{% for item in cart.items %}
<div class="cart-item">
<div class="item-image">
<img src="{{ item.product.images.first.url | filter, width: '150px' }}"
alt="{{ item.product.name }}">
</div>
<div class="item-details">
<h3><a href="{{ item.product.url }}">{{ item.product.name }}</a></h3>
{% if item.variant %}
<p class="variant">{{ item.variant.name }}</p>
{% endif %}
<p class="price">{{ item.unit_price | money_with_currency }}</p>
</div>
<div class="item-quantity">
{{ item | update_cart_item, show_quantity_buttons: true }}
</div>
<div class="item-total">
<p class="line-total">{{ item.price | money_with_currency }}</p>
</div>
<div class="item-remove">
{{ item | delete_cart_item, delete: 'Remove', class: 'btn-remove' }}
</div>
</div>
{% endfor %}
</div>
<div class="cart-summary">
<dl class="cart-totals">
<dt>Subtotal</dt>
<dd>{{ cart.subtotal_price | money_with_currency }}</dd>
{% if cart.shipping_price > 0 %}
<dt>Shipping</dt>
<dd>{{ cart.shipping_price | money_with_currency }}</dd>
{% endif %}
{% if cart.taxes > 0 %}
<dt>Tax</dt>
<dd>{{ cart.taxes | money_with_currency }}</dd>
{% endif %}
<dt class="total">Total</dt>
<dd class="total">{{ cart.grand_total | money_with_currency }}</dd>
</dl>
{{ cart | checkout_button, label: 'Proceed to Checkout', class: 'btn btn-primary btn-lg' }}
</div>
{% else %}
<p>Your cart is empty.</p>
<a href="/shop" class="btn btn-primary">Continue Shopping</a>
{% endif %}
</div>
</div>Update Cart Item
<!-- With quantity buttons -->
{{ item | update_cart_item, show_quantity_buttons: true }}
<!-- With dropdown -->
{{ item | update_cart_item, show_quantity_dropdown: true }}
<!-- With refresh button -->
{{ item | update_cart_item,
show_refresh_btn: true,
refresh_btn_label: 'Update',
refresh_btn_class: 'btn-update'
}}Delete Cart Item
{{ item | delete_cart_item }}
{{ item | delete_cart_item, delete: 'Remove', confirm: 'Are you sure?', class: 'btn-danger' }}Coupons
Apply coupons automatically:
<!-- Auto-apply on page load -->
{{ 'SUMMER2024' | auto_apply_coupon }}
<!-- Coupon form -->
<form action="/cart/apply_coupon" method="post">
<input type="hidden" name="authenticity_token" value="{{ auth_token }}">
<input type="text" name="coupon_code" placeholder="Coupon code">
<button type="submit">Apply</button>
</form>Checkout
The checkout flow is handled by Nimbu with customizable templates.
Checkout Template
templates/checkout.liquid:
<div class="checkout-page">
<div class="container">
<h1>Checkout</h1>
<div class="checkout-grid">
<!-- Order Summary -->
<div class="order-summary">
<h2>Order Summary</h2>
<div class="summary-items">
{% for item in cart.items %}
<div class="summary-item">
<span class="item-name">{{ item.product.name }} × {{ item.quantity }}</span>
<span class="item-price">{{ item.price | money_with_currency }}</span>
</div>
{% endfor %}
</div>
<dl class="summary-totals">
<dt>Subtotal</dt>
<dd>{{ cart.subtotal_price | money_with_currency }}</dd>
<dt>Shipping</dt>
<dd>{{ cart.shipping_price | money_with_currency }}</dd>
<dt>Tax</dt>
<dd>{{ cart.taxes | money_with_currency }}</dd>
<dt class="total">Total</dt>
<dd class="total">{{ cart.grand_total | money_with_currency }}</dd>
</dl>
</div>
<!-- Payment Form -->
<div class="payment-form">
{{ cart | payment_form,
title: 'Payment Information',
button_class: 'btn btn-primary btn-lg'
}}
</div>
</div>
</div>
</div>Payment Form
{{ cart | payment_form }}
{{ cart | payment_form,
id: 'payment-form',
class: 'checkout-form',
title: 'Payment Information',
value: 'Complete Purchase',
button_class: 'btn-checkout'
}}Order Downloads
When products have downloadable files, Nimbu generates the downloads for each order (for example watermarked PDFs). The order drop exposes them:
| Property | Description |
|---|---|
order.attachments | Generated downloads. Each has url, name, size, download_count and downloads_left. |
order.attachments? | true when the order has downloads. |
order.attachments_ready? | true when the downloads are generated and can be downloaded. |
order.attachments_expired? | true when the downloads were deleted after the retention period. |
Generated downloads are deleted after the Digital Download Retention (days) setting (Settings → Online Store), 30 days by default (maximum 365). After that, order.attachments is empty. A theme that only loops over the attachments shows the customer an empty space, so handle the expired state on every page that lists downloads: the order page (shop/order.liquid), the account page (customers/account.liquid) and the public download page (shop/downloads.liquid).
A signed-in customer can request fresh downloads by posting to /orders/<number>/downloads/regenerate with the CSRF token:
{% if order.attachments_expired? %}
<p>{% translate 'downloads.expired', default: 'Your downloads have expired.' %}</p>
<form method="post" action="/orders/{{ order.number }}/downloads/regenerate">
<input type="hidden" name="authenticity_token" value="{{ auth_token }}">
<button type="submit">
{% translate 'downloads.regenerate', default: 'Generate new downloads' %}
</button>
</form>
{% elsif order.attachments_ready? %}
<ul class="downloads">
{% for attachment in order.attachments %}
<li><a href="{{ attachment.url }}">{{ attachment.name }}</a></li>
{% endfor %}
</ul>
{% endif %}After the request, the customer is redirected to /account with a flash message: flash.orders.attachments_regenerating_email when the site emails the downloads, flash.orders.attachments_regenerating otherwise. When the order has nothing to generate, or a generation is already running, the customer is redirected to /account without a message.
Regenerated downloads get new links. Links from earlier emails keep failing:
- An expired private link (
/orders/<number>/downloads/...) redirects to/accountwithflash.orders.attachments_expired. - An expired or unknown public link (
/d/<token>) redirects to the homepage withflash.orders.download_unavailable.
Product Listings
Shop Homepage
templates/shop/index.liquid:
<div class="shop-page">
<div class="container">
<header class="shop-header">
<h1>{% translate 'shop.title', default: 'Shop' %}</h1>
</header>
<!-- Featured Products -->
<section class="featured-products">
<h2>Featured Products</h2>
<div class="product-grid">
{% for product in products.featured limit: 4 %}
{% include 'product-card', product: product %}
{% endfor %}
</div>
</section>
<!-- Product Collections -->
<section class="collections">
<h2>Shop by Category</h2>
<div class="collection-grid">
{% for collection in collections.all %}
<a href="{{ collection.url }}" class="collection-card">
<h3>{{ collection.name }}</h3>
<p>{{ collection.product_count }} products</p>
</a>
{% endfor %}
</div>
</section>
<!-- All Products -->
<section class="all-products">
<h2>All Products</h2>
<div class="product-grid">
{% paginate products.all by 12 %}
{% for product in paginate.collection %}
{% include 'product-card', product: product %}
{% endfor %}
{{ paginate | default_pagination }}
{% endpaginate %}
</div>
</section>
</div>
</div>Product Card Snippet
snippets/product-card.liquid:
<div class="product-card">
<a href="{{ product.url }}" class="product-link">
<div class="product-image">
{% if product.images.first %}
<img src="{{ product.images.first.url | filter, width: '400px', cropping: 'fill' }}"
alt="{{ product.name }}"
loading="lazy">
{% else %}
<img src="{{ 'product-placeholder.png' | theme_image_url }}" alt="{{ product.name }}">
{% endif %}
{% if product.on_sale %}
<span class="badge badge-sale">Sale</span>
{% endif %}
</div>
<div class="product-details">
<h3 class="product-name">{{ product.name }}</h3>
{% if product.vendor %}
<p class="product-vendor">{{ product.vendor }}</p>
{% endif %}
<div class="product-price">
{% if product.on_sale %}
<span class="sale-price">{{ product.on_sale_price | money_with_currency }}</span>
<span class="original-price">{{ product.price | money_with_currency }}</span>
{% else %}
<span class="price">{{ product.price | money_with_currency }}</span>
{% endif %}
</div>
</div>
</a>
<button class="btn-quick-add" data-product-id="{{ product.id }}">
Quick Add
</button>
</div>Advanced E-commerce Features
Customer-Specific Pricing
{% if product.advanced_pricing_enabled? %}
<p class="customer-price">
Your price: {{ product.price_for_current_customer | money_with_currency }}
</p>
{% endif %}Aggregated Products
Products with subproducts:
{% if product.aggregated? %}
<div class="subproducts">
<h3>Choose Options</h3>
{% for subproduct in product.subproducts %}
<div class="subproduct">
<h4>{{ subproduct.name }}</h4>
<p>{{ subproduct.price | money_with_currency }}</p>
{{ subproduct | add_to_cart }}
</div>
{% endfor %}
</div>
{% endif %}Stock Notifications
{% if product.current_stock == 0 %}
<form class="notify-form" action="/notify" method="post">
<p>Out of stock. Get notified when available:</p>
<input type="email" name="email" placeholder="Your email" required>
<input type="hidden" name="product_id" value="{{ product.id }}">
<button type="submit">Notify Me</button>
</form>
{% elsif product.current_stock < 5 %}
<p class="low-stock">Only {{ product.current_stock }} left in stock!</p>
{% endif %}SEO for E-commerce
Product Schema.org Markup
<div itemscope itemtype="https://schema.org/Product">
<meta itemprop="name" content="{{ product.name }}">
<meta itemprop="description" content="{{ product.description | strip_html | truncate: 200 }}">
<meta itemprop="image" content="{{ product.images.first.url }}">
<div itemprop="offers" itemscope itemtype="https://schema.org/Offer">
<meta itemprop="price" content="{{ product.effective_price | money_without_currency }}">
<meta itemprop="priceCurrency" content="{{ shop.currency }}">
{% if product.status == 'sold_out' %}
<link itemprop="availability" href="https://schema.org/SoldOut">
{% else %}
<link itemprop="availability" href="https://schema.org/InStock">
{% endif %}
</div>
</div>Best Practices
1. Always Check Stock
{% if product.current_stock > 0 %}
<!-- Show add to cart -->
{% else %}
<!-- Show out of stock message -->
{% endif %}2. Handle Empty Cart
{% if cart.items.size > 0 %}
<!-- Show cart -->
{% else %}
<p>Your cart is empty.</p>
{% endif %}3. Optimize Product Images
<img src="{{ product.image.url | filter, width: '800px', cropping: 'fill' }}" loading="lazy">4. Use Pagination for Large Catalogs
{% paginate products.all by 24 %}
{% for product in paginate.collection %}
{% include 'product-card', product: product %}
{% endfor %}
{{ paginate | default_pagination }}
{% endpaginate %}5. Cache Product Grids
{% cache 'featured-products', expires_in: 3600 %}
{% for product in products.featured %}
{% include 'product-card', product: product %}
{% endfor %}
{% endcache %}Next Steps
- Gift Cards - Sell, redeem and customize gift cards
- Channels - Custom content types with scope/filtering
- Filters - E-commerce - All cart/checkout filters
- Filters - Money - Price formatting
- Global Variables - Complete products/cart reference
Build powerful e-commerce experiences with Nimbu!