← Back to Blog
Developers

How to Build a Custom Ecommerce Theme on ShopsWired

A ShopsWired theme is a folder of Liquid templates, CSS, and JS with a manifest.json that maps URL routes to templates and their data. To build one you write the markup you want, wire pages to their route data loaders, use the built-in e-commerce filters for prices and links, and preview every keystroke live with the shopswired CLI. You own the HTML end to end — no proprietary section schema to fight.

What "custom theme" actually means here

Themes live in a directory of Liquid files. A typical theme has a master layout.liquid wrapper plus one template per page type — index.liquid, product.liquid, search.liquid, cart.liquid, checkout.liquid, account.liquid — reusable snippets/, and css/ and js/ directories. There's no hidden section framework: the markup a page renders is the markup you write.

You have two honest starting points. Build a standalone theme from scratch when you want total control of every page. Or extend the default theme and override only what you care about — usually the faster, more maintainable path, because everything you don't touch keeps tracking the parent (including future updates to checkout, account, and auth flows you probably don't want to reimplement).

Start by extending the default theme

Declare a parent with the top-level extends field in your manifest. Any file you don't ship — templates, snippets, CSS, JS — resolves from the parent, at any depth of inheritance. Your theme carries only the files it changes.

{
    "id": "aurora",
    "name": "Aurora",
    "version": "1.0.0",
    "extends": "default",
    "routes": {
        "/product/{slug}/{id}": {
            "template": "product.liquid",
            "data": "product"
        }
    },
    "settings": [
        {
            "tab": "General",
            "key": "accent_color",
            "type": "color",
            "default": "#111827",
            "label": "Accent color"
        }
    ]
}

The routes map is the heart of the manifest. Each entry binds a URL pattern to a template file and a named route data loader — here, the product loader pre-populates the page context with the full product model before Liquid runs. Other loaders (order-detail, and the built-in cart, search, checkout contexts) work the same way: the platform fetches and shapes the data, your template just renders it. The current loader is also exposed to templates as dataloader, so you can branch on which page you're on.

Templates, layout, and block-level overrides

The layout owns your <html>, <head>, and <body>, and must include content_for_layout where page content is injected. Individual templates fill it in. When you extend a parent theme, you don't have to copy a whole file to change one region — the default theme wraps its most-customized areas in named blocks (product_title, add_to_cart, product_gallery, price, and more). Extend the parent file and override only the blocks you want:

{% extends parent %}

{% block product_title %}
  <h1 class="pdp-title">{{ product.name }}</h1>
  {% if product.compare_price | present %}
    <span class="pill">Sale</span>
  {% endif %}
{% endblock %}

{% block add_to_cart %}
  {% if product | in_stock %}
    <button type="submit" class="cta">Add to bag — {{ product.price | money }}</button>
  {% else %}
    <button disabled>Sold out</button>
  {% endif %}
{% endblock %}

Everything you didn't override still renders the parent's version, so when the default theme improves its gallery or cart markup, you inherit it automatically. Use {{ block.super }} inside a block to keep the parent's content and add to it rather than replace it. This is the single biggest maintenance win over copy-the-whole-file theming.

The e-commerce filters that do the heavy lifting

ShopsWired's Liquid engine ships custom filters built for commerce, so you're not hand-rolling money math or URL logic. The essentials:

Put together, a product card is almost entirely filters:

<a href="{{ product | product_url }}" class="card">
  <img src="{{ product.image_url | cdn_url | append: '?s=400' }}"
       alt="{{ product.name | escape }}">
  <h3>{{ product.name }}</h3>
  <p>{{ product.desc | truncate_words: 18 }}</p>
  <span class="price">{{ product.price | money }}</span>
</a>

One quiet payoff: tiered/B2B pricing is transparent to your theme. When a logged-in customer has a price level assigned, product.price and product.compare_price already reflect it everywhere — cards, PDP, search grids — so you keep using {{ product.price | money }} and it just works.

Render hooks and computed data

Two mechanisms let plugins and computed values flow into your markup cleanly. First, drop {% hook 'name' %} tags at extension points (the default theme exposes dozens — product_after_price, product_after_description, header_nav_end, and more) so installed plugins can inject reviews, related products, or a currency switcher without touching your files.

Second, when you need a derived value on the page, a small plugin can compute it in a template.before_render hook and set it on the bindings — scoped to the right data loader so it only runs where needed:

// manifest.json: { "path": "enrich.js", "dataloaders": ["product"] }
module.exports = {
    "template.before_render": function (ctx) {
        var p = ctx.data.bindings.product;
        if (p && p.compare_price) {
            ctx.data.bindings.savings_pct =
                Math.round((1 - p.price / p.compare_price) * 100);
        }
    }
};

Your template then reads it like any other variable: {% if savings_pct > 0 %}Save {{ savings_pct }}%{% endif %}. Keeping the data-fetching in a before_render and the rendering in Liquid is the pattern the platform is built around. These handlers run server-side on ShopScript, ShopsWired's synchronous JavaScript runtime, and always take ctx as their first argument.

Preview every change live with the CLI

You don't upload themes to iterate. Enable Developer Mode on your shop, then work locally and watch changes appear on the real storefront instantly:

shopswired login
shopswired link
shopswired init --type theme --id aurora
shopswired dev

shopswired dev serves your local files on demand, watches for changes, and streams any plugin console.log output to your terminal. Preview at your normal shop URL — no separate build step, no extra subdomain. When you're happy, shopswired push --type theme --id aurora installs it permanently (the same as "Install From Zip" in the dashboard). There's also an MCP server (shopswired mcp) if you'd rather drive edits from an AI agent.

One caching contract to respect

The honest trade-off of a fast storefront: high-traffic pages are edge-cached and shared across anonymous visitors, so the server renders cart_count as 0 on those pages. Never render cart-derived content (a mini-cart, a free-shipping progress bar) server-side on a cacheable page — it would bake into the shared cache. Instead, read the JS-readable cart_count cookie on load and hydrate the badge client-side, exactly as the default theme's js/main.js does. Personal pages (cart, checkout, account) are never cached, so the server-rendered values there are real. Carry the default theme's WebP fallback logic over too if you build a fully custom layout. The full contract and every binding are in the Theme API reference.

Ship it

Extend the default theme, override the blocks you care about, lean on the e-commerce filters, and iterate against a live shop with the CLI. Because ShopsWired charges 0% platform transaction fees on every plan — you pay only your processor's standard rate — the store you build keeps more of every sale it makes. Browse real storefronts on the showcase for ideas, then start building.

← Back to Blog