Building a Shopify storefront with @studiometa/ui
22/07/2026 in #shopify #progressive-enhancement #liquidThe series
A 7-part series on building a Shopify storefront by progressively enhancing server-rendered Liquid with @studiometa/ui:
- Building a Shopify storefront with
@studiometa/ui(this page) - Shopify Section Rendering API example
- Shopify AJAX collection filtering
- Shopify variant selector
- Shopify AJAX cart drawer
- Shopify predictive search
- 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 is a library of JavaScript components built on @studiometa/js-toolkit, a lightweight progressive-enhancement framework. Both are open-source packages we build and maintain at ikko. 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:
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.
Table of content
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, the Ajax Cart API, 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:
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:
import { import registerComponentsregisterComponents } from '@studiometa/js-toolkit';
import { class Action<T extends BaseProps = BaseProps>Action class.
Action, class Fetch<T extends BaseProps = BaseProps>Fetch class.
Fetch, class Transition<T extends BaseProps = BaseProps>Transition class.
Transition } from '@studiometa/ui';
import registerComponentsregisterComponents(class Action<T extends BaseProps = BaseProps>Action class.
Action, class Fetch<T extends BaseProps = BaseProps>Fetch class.
Fetch, class Transition<T extends BaseProps = BaseProps>Transition class.
Transition);
From there, everything is declared in markup. Three attributes carry the whole model:
data-component="Fetch"attaches a component (hereFetch) to an element.data-option-*sets that component's options, for exampledata-option-historyto 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 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 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:
// Simulated storefront: the playground iframe has no backend.
// `data-option-history` calls history.pushState, which the sandbox blocks, so stub it.
module history
var history: History
The Window.history read-only property returns a reference to the History object, which provides an interface for manipulating the browser session history (pages visited in the tab or frame that the current page is loaded in).
history.History.pushState(data: any, unused: string, url?: string | URL | null): voidThe pushState() method of the History interface adds an entry to the browser's session history stack.
pushState = () => {};
module history
var history: History
The Window.history read-only property returns a reference to the History object, which provides an interface for manipulating the browser session history (pages visited in the tab or frame that the current page is loaded in).
history.History.replaceState(data: any, unused: string, url?: string | URL | null): voidThe replaceState() method of the History interface modifies the current history entry, replacing it with the state object and URL passed in the method parameters.
replaceState = () => {};
const const realFetch: ((input: RequestInfo | URL, init?: RequestInit) => Promise<Response>) & {
(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
(input: string | URL | Request, init?: RequestInit): Promise<Response>;
}
realFetch = module window
var window: Window & typeof globalThis
The window property of a Window object points to the window object itself.
window.function fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>fetch.CallableFunction.bind<((input: RequestInfo | URL, init?: RequestInit) => Promise<Response>) & {
(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
(input: string | URL | Request, init?: RequestInit): Promise<Response>;
}>(this: ((input: RequestInfo | URL, init?: RequestInit) => Promise<Response>) & {
(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
(input: string | URL | Request, init?: RequestInit): Promise<Response>;
}, thisArg: unknown): ((input: RequestInfo | URL, init?: RequestInit) => Promise<Response>) & {
(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
(input: string | URL | Request, init?: RequestInit): Promise<Response>;
} (+1 overload)
For a given function, creates a bound function that has the same body as the original function.
The this object of the bound function is associated with the specified object, and has the specified initial parameters.
bind(module window
var window: Window & typeof globalThis
The window property of a Window object points to the window object itself.
window);
const const pages: {
'/': string;
'/pages/shipping': string;
}
pages = {
'/': `<main id="main-content">…</main>`,
'/pages/shipping': `<main id="main-content">…</main>`,
};
module window
var window: Window & typeof globalThis
The window property of a Window object points to the window object itself.
window.function fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>fetch = async (input: URL | RequestInfoinput, init: RequestInit | undefinedinit) => {
// Fetch calls window.fetch with a URL instance, so read input.href.
const const url: URLurl = new var URL: new (url: string | URL, base?: string | URL) => URLThe URL interface is used to parse, construct, normalize, and encode URL.
URL class is a global reference for import { URL } from 'node:url'
https://nodejs.org/api/url.html#the-whatwg-url-api
URL(
typeof input: URL | RequestInfoinput === 'string' ? input: stringinput : input: URL | Requestinput instanceof var URL: {
new (url: string | URL, base?: string | URL): URL;
prototype: URL;
canParse(url: string | URL, base?: string | URL): boolean;
createObjectURL(obj: Blob | MediaSource): string;
parse(url: string | URL, base?: string | URL): URL | null;
revokeObjectURL(url: string): void;
}
The URL interface is used to parse, construct, normalize, and encode URL.
URL class is a global reference for import { URL } from 'node:url'
https://nodejs.org/api/url.html#the-whatwg-url-api
URL ? input: URLinput.URL.href: stringThe href property of the URL interface is a string containing the whole URL.
href : input: Requestinput.Request.url: stringThe url read-only property of the Request interface contains the URL of the request.
url,
'http://localhost',
);
const const page: anypage = const pages: {
'/': string;
'/pages/shipping': string;
}
pages[const url: URLurl.URL.pathname: stringThe pathname property of the URL interface represents a location in a hierarchical structure.
pathname];
if (const page: anypage) return new var Response: new (body?: BodyInit | null, init?: ResponseInit) => ResponseThe Response interface of the Fetch API represents the response to a request.
Response(const page: anypage, { ResponseInit.headers?: HeadersInit | undefinedheaders: { 'content-type': 'text/html' } });
return const realFetch: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>realFetch(input: URL | RequestInfoinput, init: RequestInit | undefinedinit);
};
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 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: fetch and swap server-rendered theme sections, the foundation for everything below (plus the July '26
{% partial %}preview). - 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: reflect variant choices into price, availability and the buy button with the reactive
Datacomponents. - Shopify AJAX cart drawer: add to cart and open an accessible slide-in drawer with focus trapping and scroll lock.
- Shopify predictive search: a debounced, keyboard-navigable suggestions dropdown over the predictive search endpoint.
- 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, the technique the rest of the series leans on.