---
date: 2026-07-27
title: Building a Shopify storefront with @studiometa/ui
description: "A progressive-enhancement approach to Shopify theme interactivity: build interactive storefront features on top of your server-rendered Liquid with @studiometa/ui and @studiometa/js-toolkit."
tags: shopify, progressive-enhancement, liquid, studiometa-ui, storefront
---

# Building a Shopify storefront with @studiometa/ui

27/07/2026 in #shopify #progressive-enhancement #liquid

> **The series**
>
> **A 7-part series** on building a Shopify storefront by progressively enhancing server-rendered Liquid with `@studiometa/ui`:
> 1. Building a Shopify storefront with `@studiometa/ui` (this page)
> 2. [Shopify Section Rendering API example](/articles/shopify-section-rendering-api-example)
> 3. [Shopify AJAX collection filtering](/articles/shopify-ajax-collection-filtering)
> 4. [Shopify variant selector](/articles/shopify-variant-selector)
> 5. [Shopify AJAX cart drawer](/articles/shopify-ajax-cart-drawer)
> 6. [Shopify predictive search](/articles/shopify-predictive-search)
> 7. [Shopify recommendations, tracking and prefetching](/articles/shopify-recommendations-tracking-and-prefetching)
>

Shopify themes render on the server. Liquid produces HTML, the browser gets a page that works, and Google gets something to index. Adding richer interactions like AJAX collection filtering, a cart drawer or predictive search usually means writing and wiring a fair amount of JavaScript by hand.

[`@studiometa/ui`](https://ui.studiometa.dev) is a library of JavaScript components built on [`@studiometa/js-toolkit`](https://js-toolkit.studiometa.dev), a lightweight progressive-enhancement framework. Both are open-source packages we build and maintain at [ikko](https://ikko.fr). You declare behaviour with `data-` attributes on the HTML your theme already renders, and the components do the wiring. This first article sets up the component model; across the series I will build the storefront's interactive pieces one at a time, each on the Liquid your theme already renders.

Here is the whole idea in one demo. It is a storefront navigation that swaps the page's main region without a full reload, with a loader during the request:

```twig
<!-- demo.twig -->
<div
  data-component="Action"
  data-on:fetch-before="Transition(#loader) -> target.enter()"
  data-on:fetch-after="Transition(#loader) -> target.leave()"
  class="max-w-xl space-y-6">
  <nav class="flex items-center gap-4">
    <a href="/" data-component="Fetch" data-option-history class="border-b border-current">
      Home
    </a>
    <a href="/pages/shipping" data-component="Fetch" data-option-history class="border-b border-current">
      Shipping
    </a>
    <span
      id="loader"
      data-component="Transition"
      data-option-enter-from="opacity-0"
      data-option-leave-to="opacity-0"
      data-option-leave-keep
      class="opacity-0 text-sm text-current/60">
      Loading…
    </span>
  </nav>

  <main id="main-content" class="space-y-3">
    <h1 class="text-xl font-bold">Home</h1>
    <p>Welcome to the shop. Browse our latest products.</p>
  </main>
</div>
```

```ts
// demo.ts
import { registerComponents } from '@studiometa/js-toolkit';
import { Action, Fetch, Transition } from '@studiometa/ui';

// --- Simulated storefront ---------------------------------------------------
// The playground has no backend, so we mock the two routes and return the HTML
// a Liquid page would render. On a real store these are normal pages already;
// delete this block to go live (see "Swap the mock for your store" below).
// `data-option-history` calls history.pushState, which the sandboxed playground
// iframe blocks (SecurityError). Stub it so the URL sync is a no-op in the demo.
history.pushState = () => {};
history.replaceState = () => {};

const realFetch = window.fetch.bind(window);
const pages: Record<string, string> = {
  '/': `<main id="main-content" class="space-y-3">
    <h1 class="text-xl font-bold">Home</h1>
    <p>Welcome to the shop. Browse our latest products.</p>
  </main>`,
  '/pages/shipping': `<main id="main-content" class="space-y-3">
    <h1 class="text-xl font-bold">Shipping</h1>
    <p>We ship worldwide in 2 to 5 business days.</p>
  </main>`,
};
window.fetch = async (input, init) => {
  const url = new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url, 'http://localhost');
  const page = pages[url.pathname];
  if (page) {
    await new Promise((resolve) => setTimeout(resolve, 500)); // fake latency
    return new Response(page, { headers: { 'content-type': 'text/html' } });
  }
  return realFetch(input, init);
};
// ---------------------------------------------------------------------------

registerComponents(Action, Fetch, Transition);
```

The links are ordinary `<a href>` elements. Without JavaScript they navigate like any link. With JavaScript, `@studiometa/ui` intercepts the click, fetches the target, and swaps the matching region in place. That is progressive enhancement: the server-rendered version is the baseline, and the enhancement is a layer on top that can fail without breaking the page.

## Progressive enhancement on a Shopify theme

With progressive enhancement, Liquid stays the source of truth for markup. The server renders a complete, indexable page. JavaScript then attaches behaviour to elements that are already there, using `data-` attributes, and talks to the endpoints Shopify already exposes: the [Section Rendering API](https://shopify.dev/docs/api/ajax/section-rendering), the [Ajax Cart API](https://shopify.dev/docs/api/ajax/reference/cart), the predictive search endpoint.

`@studiometa/js-toolkit` is a framework for exactly this: it binds component classes to DOM elements through data attributes, so the behaviour is declared in your Liquid and the logic lives in small, testable classes. The rendering stays with your theme; the toolkit only adds behaviour.

## The setup

Install the two packages:

```sh
npm install @studiometa/ui @studiometa/js-toolkit
```

Register the components you use once, in your theme's main script. `registerComponents` mounts every element carrying the matching `data-component` attribute:

```js twoslash
// @noErrors
import { registerComponents } from '@studiometa/js-toolkit';
import { Action, Fetch, Transition } from '@studiometa/ui';

registerComponents(Action, Fetch, Transition);
```

From there, everything is declared in markup. Three attributes carry the whole model:

- `data-component="Fetch"` attaches a component (here [`Fetch`](https://ui.studiometa.dev/components/Fetch/)) to an element.
- `data-option-*` sets that component's options, for example `data-option-history` to update the URL.
- `data-ref="…"` marks a child element the component reads or writes.

Two small components do most of the wiring across the series. [`Action`](https://ui.studiometa.dev/components/Action/) connects an event on one element to a method call on another, declaratively: `data-on:fetch-before="Transition(#loader) -> target.enter()"` reads as "when a `fetch-before` event bubbles up here, call `enter()` on the `Transition` component at `#loader`". The `(#loader)` selector scopes the target to that one element; a bare `Transition` would match every instance on the page. [`Transition`](https://ui.studiometa.dev/components/Transition/) runs enter/leave class transitions, which is how the loader above appears and disappears. You write the behaviour in Liquid; the classes stay in your control.

## How the demo works

The navigation above is a `Fetch` component on each link plus one `Action`/`Transition` pair for the loader. When you click a link, `Fetch` intercepts the click (it leaves modifier-clicks and `target="_blank"` alone), fetches the URL, and replaces the element in the response whose `id` matches an element on the page, here `#main-content`. With `data-option-history` it also pushes the new URL so the back button works. A production AJAX navigation should go one step further than swapping markup: move focus to the new `#main-content`, announce the change to assistive technology (for example through a live region), and update the document title, since a content swap alone does none of these.

The demo runs in a playground with no backend, so its script mocks `window.fetch` to return the HTML each route would render:

```js twoslash
// @noErrors
// Simulated storefront: the playground iframe has no backend.
// `data-option-history` calls history.pushState, which the sandbox blocks, so stub it.
history.pushState = () => {};
history.replaceState = () => {};

const realFetch = window.fetch.bind(window);
const pages = {
  '/': `<main id="main-content">…</main>`,
  '/pages/shipping': `<main id="main-content">…</main>`,
};
window.fetch = async (input, init) => {
  // Fetch calls window.fetch with a URL instance, so read input.href.
  const url = new URL(
    typeof input === 'string' ? input : input instanceof URL ? input.href : input.url,
    'http://localhost',
  );
  const page = pages[url.pathname];
  if (page) return new Response(page, { headers: { 'content-type': 'text/html' } });
  return realFetch(input, init);
};
```

**Swap the mock for your store:** delete the simulation block (the `history` stub and the `window.fetch` override). The links already point at real Liquid pages (`/`, `/pages/shipping`), and `Fetch` will swap the region whose `id` matches. To target a single, server-defined region precisely rather than "any matching id", scope it with `data-option-selector`, or fetch a named section with the Section Rendering API, which is exactly where the next article starts. You can also [open this demo in the playground](https://ui.studiometa.dev/play/#html=eNq1U7FO5EAM7fcrrLkGitmA7kRxSiLdFacrrjsk6tnE2YxIxqMZ77Lb8TV8GF%2BCJ5mAWJCgIU2s%2BNl%2B79kpW7tfAbSGjW5o9OTQcaV%2BNWzJqSVD7meH3PR6gx0FrNR1MC7ahDn7NpBpMZyDroFN2CKvpQWGs%2FO35aaTxEfVA5o9ztXNYGKs1GgO%2Bk4fBojeNKiP%2BkrVki2d2S%2BQbsADWMYx6mYaD1vj9Y8JJ0gDfcCuUoV6I%2FVPYpY%2Fk0%2BkdG8jUzguvTcUhKLeQA6aXQhSmnsD%2FKUR5zGFOZ3nzRZjEXvrvXXbr5j%2BP%2Fc%2BYSBWuYywbaVmm5On6Tll8bKQV4hMaPJTd4HGSpFswPJRX7wHnDanmT4Fu0X0OZmVPhcB44F1HOd3FlxcXTxr%2FidqRPLj%2FUNWndROJ1HITdSrFI3Gukl6CkSsiBDXllnLJX1fLqS%2FXFLTUDm2Tkr0hoZW1WnDZdFfZqyvb3AQ%2BxCYgHuE2JNfw%2B9AdxGBdgEGwxgZfKB213Bcl4Wf6SUy9aos5LerV08AWxxn&script=eNqtVclu5DYQvfdXFPoiNaBlJsFc3Es2JJiDgwRuBzkEAcwWq1uMKVIhSyMLhv89VZTsLJ4cBpgGGpJYr54eqx5Lput9IHiEgBcTCcN3nlccOorwBOfgO8i%2BjjRo4zskVf8RS%2FLe3hvKtquX5G8aMt4V8ANS0xZwG5SLRpY%2ByjEYzl3VNZRlCUfTDVYRaojkAzLakQQ%2B9Sd8ty1Cb9V0CX5wGloVwXk4qeYenS4gehgROt%2FcAzGSRg8MJIygGB2QhuBS5P3tj9fCp%2BDa%2FDkYDb26IIx%2BsAJzGkMFPzkOB1R21i15EUHxnfOh42XJYWbLGD1thU6jRRKkiXCySYaHiwdrPiDkERHWx1H1SUJSefYBJj%2BE%2BRVrOKH146YSrjutSJW%2BlyKXTMiA6Q4aZW2E5bHqh9geiWtbwNiapk3Ekfd68g9c778rJYTmHFSHs64I%2BRGbIRiavg%2FBh00FRxpOYEhqKCy%2F3FxDnFwDvBXFO2YlYObiaex8tXqlAfaQb2B%2FgMen7Us0IIto8DVg1XgXKdU3eYqDo3Haj9VZHqsTP%2BTzyma7gFPBr%2BAGGx%2F0LlIw7sJNT9cDEzyuALI6u4K7XadYrNH7tdyUnE5s%2BDU0VsW4X8eeNZVT%2BeX6wCkAu%2Fbtc4jwgcoHy51xVJ681evDezb1rm7fLtj%2B8Cvahtekuangre8r%2BDb4kf0hzRS3i9zg9dBQrHZ1L7m7WsQc7ookM22mjq3pe5b%2F2UUfF%2BL%2FCGexpmejB6tHo1Fa%2BoXs4x2chmgcxghaTR%2BRzC37Z3%2B42irZIzeuH6hgIkNzczltbtcQLMMcjmKmnKYe%2FZlxDIf9fg%2FZ3LgMvloWr5ar4WTlGkGLC5dw1fLseMZUzF1A1hL1V3XNjla29ZEytsrz29OJ3s%2Be%2BY3hVa%2BodXwCfheMOUMuoU3SC6BGxd4XrT%2FzNDMR8zxg9PYDI3hTEenWdMiz5Hm5gHdv3mw2W%2BCTdVb3mJrumimxLZNG6G4w8rRlPnlbwZO05WmBgW38CNnS4lJqww7IpI91S53NeKw%2Bpc088X%2Bhezkr%2F6r5Vlozj9rP9VutXn8r8v%2F9ArCCvwB6yBBb&theme=light) to edit it live.

Every demo in this series works the same way: the component markup is exactly what you ship, and only the mocked endpoint is demo scaffolding.

## The building blocks

Each interactive piece of a storefront gets its own article, its own primary technique, and its own working demo. Start anywhere; they all build on the setup above.

- **[Shopify Section Rendering API example](/articles/shopify-section-rendering-api-example)**: fetch and swap server-rendered theme sections, the foundation for everything below (plus the July '26 `{% partial %}` preview).
- **[Shopify AJAX collection filtering](/articles/shopify-ajax-collection-filtering)**: filter and sort a collection with a `<form method="get">`, updating the grid and the URL without a reload.
- **[Shopify variant selector](/articles/shopify-variant-selector)**: reflect variant choices into price, availability and the buy button with the reactive `Data` components.
- **[Shopify AJAX cart drawer](/articles/shopify-ajax-cart-drawer)**: add to cart and open an accessible slide-in drawer with focus trapping and scroll lock.
- **[Shopify predictive search](/articles/shopify-predictive-search)**: a debounced, keyboard-navigable suggestions dropdown over the predictive search endpoint.
- **[Shopify recommendations, tracking and prefetching](/articles/shopify-recommendations-tracking-and-prefetching)**: lazy-load the recommendations section, then track analytics events and prefetch links.

## A note on the demos

The playground runs Twig, HTML, JavaScript and CSS, not Liquid against a real Shopify store. So every demo is a simulation: the component markup is production-accurate, but the network responses are mocked with a small `window.fetch` shim, and Twig stands in for Liquid. Each article shows the real Shopify wiring next to the demo and tells you exactly what to delete to go live. This keeps each demo runnable without shipping a companion theme to clone.

The navigation above swaps a region, shows a loader and keeps the back button working, from a few attributes and no custom fetch code.

> **Next article**
>
> [The Section Rendering API](/articles/shopify-section-rendering-api-example), the technique the rest of the series leans on.
