Skip to content

Terminal

Blueprint terminal ready. Run help to see available commands.

Press Tab to complete commands and paths

Keyboard shortcuts

9 min read By Billy Noyes

Alpine.js for Shopify themes: a practical architecture

Shopify Liquid, Alpine.js, and browser interaction shown as three connected layers

Last verified: September 22, 2026 against Alpine.js v3 documentation and Shopify's current theme architecture, Section Rendering, and theme editor guidance.

Shopify Liquid and Alpine.js work well together because they solve different parts of the same storefront.

Liquid should render the product data, content, forms, URLs, translations, and accessible HTML that make the page useful. Alpine should enhance that HTML with focused interaction: opening a drawer, changing selected state, updating a counter, or coordinating a small component.

The strongest architecture is not “build a Shopify site in Alpine.” It is “let Shopify render the truth, then let Alpine enhance the moment.”

The division of responsibility

A reliable theme keeps each layer explicit:

  • Liquid owns storefront truth. Products, variants, prices, localization, routes, settings, and initial HTML come from Shopify.
  • Alpine owns nearby browser state. It coordinates what is open, selected, pending, or visible.
  • Shopify endpoints and actions own commerce changes. The browser requests a change, then reconciles the interface with Shopify's response.
  • CSS owns presentation. Alpine changes state and attributes rather than becoming a styling system.

This keeps the page useful before Alpine starts and prevents client state from quietly becoming a second, conflicting source of commerce data.

Load Alpine once

When using a bundle, register plugins, stores, and reusable Alpine.data() components before calling Alpine.start(). Call Alpine.start() once for the page.

import Alpine from 'alpinejs';
import focus from '@alpinejs/focus';
import productGallery from './product-gallery.js';

Alpine.plugin(focus);
Alpine.data('productGallery', productGallery);

window.Alpine = Alpine;
Alpine.start();

If Alpine is loaded from a script tag instead, use the alpine:init event to register stores, components, directives, and plugins before Alpine initializes the document.

Do not call Alpine.start() again after every Shopify section update. Alpine observes DOM additions after startup. Re-starting it can initialize existing components twice.

Start with server-rendered HTML

A theme should remain understandable before Alpine starts. Render product titles, prices, variant options, navigation, cart contents, and article content with Liquid first.

Alpine can then control presentation without becoming the source of the content:

<div x-data="{ open: false }">
  <button
    type="button"
    @click="open = !open"
    :aria-expanded="open.toString()"
    aria-controls="ProductDetails-{{ section.id }}"
  >
    Product details
  </button>

  <div
    id="ProductDetails-{{ section.id }}"
    x-cloak
    x-show="open"
  >
    {{ product.description }}
  </div>
</div>

Liquid owns the description. Alpine owns whether it is disclosed.

For x-cloak to prevent a flash before Alpine initializes, include [x-cloak] { display: none !important; } in the theme stylesheet.

Keep components local by default

Use x-data around the smallest useful component. A product gallery, variant picker, accordion, or search disclosure should not need unrelated page state.

Local state makes theme sections easier to move, duplicate, and re-render. It also prevents one global object from becoming an undocumented application framework inside the theme.

Extract repeated behaviour into Alpine.data(). Use an Alpine.store() only when state genuinely crosses component boundaries. A cart drawer is a good example: product forms, the header count, and the drawer can respond to one small cart store. An accordion is not.

Prefer x-show for frequently toggled content because the element remains in the DOM. Use x-if when the element genuinely needs to be created and destroyed, remembering that its component state and listeners are recreated too.

Pass Shopify data into Alpine safely

Liquid already has the data during rendering. Pass only the values the component needs and serialize them with Shopify's json filter rather than assembling JavaScript strings by hand.

Keep Shopify IDs as strings in JavaScript. That avoids accidental precision loss as numeric IDs grow:

<div
  x-data='{
    productId: {{ product.id | append: "" | json }},
    selectedVariantId: {{ product.selected_or_first_available_variant.id | append: "" | json }}
  }'
>
  ...
</div>

For larger objects, use a dedicated application/json script element or a narrowly scoped data attribute, then parse it in the component. Do not place an entire product object into every x-data expression.

Avoid using x-html with merchant, customer, URL, or other untrusted content. Prefer Liquid-rendered markup or x-text so the browser does not interpret data as HTML.

Keep product forms durable

A product form should remain a normal Shopify product form. Alpine can synchronize the selected variant, but the named id input and server-rendered selected option remain the durable submission path:

<div
  x-data='{ selectedVariantId: {{ product.selected_or_first_available_variant.id | append: "" | json }} }'
>
  {% form 'product', product %}
    <label for="Variant-{{ section.id }}">Variant</label>
    <select
      id="Variant-{{ section.id }}"
      name="id"
      x-model="selectedVariantId"
    >
      {% for variant in product.variants %}
        <option
          value="{{ variant.id }}"
          {% if variant.id == product.selected_or_first_available_variant.id %}selected{% endif %}
          {% unless variant.available %}disabled{% endunless %}
        >
          {{ variant.title }} — {{ variant.price | money }}
        </option>
      {% endfor %}
    </select>

    <button type="submit">Add to cart</button>
  {% endform %}
</div>

A production variant picker will usually update media, price, availability, and the URL as well. Those values should come from Shopify's variant data or freshly rendered sections rather than being guessed from presentation text.

Ask Shopify to render dynamic content

When an interaction needs fresh server data, use Shopify's Section Rendering API instead of rebuilding Liquid output in JavaScript. Shopify renders the section with its current Liquid context and returns HTML.

Build locale-aware requests from window.location.pathname or window.Shopify.routes.root. When requesting several sections, a section that fails to render is returned as null inside a successful response.

async function updateProductSection(sectionId, variantId) {
  const url = new URL(window.location.href);
  url.searchParams.set('variant', variantId);
  url.searchParams.set('sections', sectionId);

  const response = await fetch(url);
  if (!response.ok) return;

  const sections = await response.json();
  const html = sections[sectionId];
  if (!html) return;

  const currentSection = document.getElementById(`shopify-section-${sectionId}`);
  if (currentSection) currentSection.outerHTML = html;
}

The response contains the complete Shopify section wrapper. Replace that wrapper rather than inserting it inside itself and creating duplicate shopify-section-* IDs.

Scripts inside HTML inserted through innerHTML, DOMParser, or outerHTML do not execute. Put reusable behaviour in the main JavaScript bundle, use delegated events from a stable ancestor, use Alpine components, or use custom elements whose connectedCallback() owns initialization.

Alpine's mutation observer initializes directives added after Alpine.start() and cleans up removed Alpine trees. Non-Alpine libraries still need an explicit lifecycle.

Design for the theme editor lifecycle

The theme editor can add, remove, reorder, and re-render sections without reloading the page. Shopify emits events including shopify:section:load and shopify:section:unload.

Use those events to initialize and clean up non-Alpine behaviour. Keep event handlers scoped to the section that owns them, and remove browser or third-party listeners when the section unloads.

Reusable Alpine.data() components can define init() and destroy() methods. Alpine calls destroy() before cleaning up the component, which is the right place to remove manual listeners, timers, observers, and third-party instances.

Add {{ block.shopify_attributes }} to block wrappers so the theme editor can identify and select them correctly.

Use the platform's cart contract

Keep product, cart, search, localization, and contact forms intact as durable browser paths. Enhance submission when the interaction benefits from it, but reconcile the UI with Shopify's response before treating the change as final.

Shopify's standard storefront actions provide getCart, updateCart, and openCart across Liquid storefronts. Themes can configure the presentation while apps and agents call one shared contract. The matching standard events let integrations observe product, collection, search, and cart activity without scraping the DOM.

I cover that contract in Standard events and actions for Liquid storefronts.

Accessibility is part of the state

When Alpine changes what somebody can see, it must also update what assistive technology understands.

  • Bind aria-expanded on disclosure controls.
  • Use aria-controls to identify the controlled region.
  • Move focus deliberately when opening and closing dialogs.
  • Return focus to the trigger when a dialog closes.
  • Support Escape and focus trapping for modal interfaces.
  • Respect reduced-motion preferences.
  • Use native elements such as details, summary, dialog, and popover when they already solve the problem.

Alpine should improve an existing interaction, not make basic access depend on JavaScript.

Performance rules that hold up

  • Render essential content with Liquid instead of waiting for JavaScript.
  • Load one Alpine runtime and start it once.
  • Keep component inputs small.
  • Lazy-load page-specific modules and heavy third-party libraries.
  • Use x-show for frequent toggles instead of repeatedly rebuilding the DOM.
  • Fetch the smallest useful set of Shopify sections.
  • Avoid broad DOM queries and global mutation handlers inside every component.
  • Measure on a real mobile storefront, not only inside the theme editor.

Tools around this architecture

These tools are optional. They support the workflow without changing the separation between Liquid, Alpine, and Shopify:

  • Easel adds Vite, HMR, named bundles, and generated Liquid to new or existing Shopify themes while managing only its own build output.
  • Proof builds a theme pull request, deploys a preview theme, and adds storefront and Theme Editor links to the pull request.
  • alpine-mcp gives coding agents local search and retrieval across a current, commit-pinned snapshot of the official Alpine.js documentation.
  • Alpine.js Swiper wraps Swiper with Alpine directives, magic properties, reactive state, and lifecycle cleanup for themes that genuinely need a carousel.

For more detail on the build and review layers, see Easel for Shopify Liquid themes and Proof for Shopify theme pull request previews.

A production checklist

  • Render meaningful content and controls with Liquid first.
  • Register plugins, stores, and components before starting Alpine.
  • Call Alpine.start() once.
  • Keep x-data scoped to one component.
  • Use Alpine stores only for genuinely shared state.
  • Serialize Liquid values with json and keep Shopify IDs as strings.
  • Keep normal form inputs and actions as the durable path.
  • Use locale-aware Section Rendering requests.
  • Replace the complete section wrapper and handle null responses.
  • Do not rely on scripts inside replaced HTML executing.
  • Clean up timers, observers, listeners, and third-party instances.
  • Bind accessibility attributes to the same state as the interface.
  • Test section reloads, block selection, mobile viewports, keyboard navigation, and reduced motion.

This approach keeps the storefront recognisably Shopify: server-rendered, localized, theme-editor friendly, and built around platform primitives. Alpine adds the interaction without turning the theme into a single-page application.

Primary references

Search suggestions are unavailable. Try again.

Loading...