October 25, 2026 · 8 min read

Shopify Liquid + Vue 3: The Architecture Guide for Theme Developers

Learn exactly how to wire Vue 3 into a Shopify Liquid theme without going headless, using defineCustomElement, the Section Rendering API, and scoped

Shopify Liquid + Vue 3: The Architecture Guide for Theme Developers

The cleanest way to combine Shopify Liquid and Vue 3 is not a full SPA mounted at #app. It is a set of surgically placed Custom Elements, each powered by defineCustomElement, that receive server-rendered Liquid data as HTML attributes and manage their own reactive state from there. Liquid handles data serialization and initial markup; Vue 3 handles interactivity. The two layers never fight over the DOM.

Key takeaways

  • Vue 3's defineCustomElement converts any .ce.vue Single File Component into a native Custom Element you can drop into any Liquid template.
  • Liquid serializes Shopify data to JSON strings via the json filter; Vue reads those strings as props or dataset attributes on mount.
  • The Section Rendering API (and its bundled cart variant) handles server-side re-renders without a full page reload, so Vue stays the reactive layer while Liquid stays the data source.
  • Avoid Shadow DOM for theme components. Shopify's own Dawn and Horizon themes use Custom Elements with light DOM specifically so theme CSS and the Theme Editor can reach inside.
  • Vue 3 reached stable release 3.5.40 in July 2026, so the API surface this post targets is current and stable.

Why not mount Vue at the root?

Every "integrate Vue into Shopify" tutorial you've seen mounts a single root instance over the entire page. That breaks the Theme Editor: the editor communicates with sections via postMessage and expects to be able to add, remove, and reorder section DOM nodes. A root Vue instance that owns the whole document will fight that. You lose live preview, section highlighting, and block dragging.

The correct mental model is: Liquid renders the page. Vue owns individual interactive islands. This matches exactly how Shopify's own themes work. Dawn and Horizon register plain Custom Elements (class ProductForm extends HTMLElement) for cart, variant, and media interactivity, while everything else is server-rendered HTML from Liquid. Vue 3 lets you do the same thing with far less boilerplate, because defineCustomElement turns a full Composition API component into a browser-native custom element in a handful of lines.

The three-layer stack

LayerTechnologyResponsibility
Data & markupShopify LiquidServer-render products, collections, cart, metafields
Reactive islandsVue 3 Custom ElementsVariant pickers, mini-cart, quantity steppers, image zoom
Section updatesSection Rendering APIRe-fetch server-rendered HTML after cart mutations

This split means you are not duplicating Liquid logic in JavaScript. The server stays the source of truth for prices, availability, and metafields. Vue only manages what changes in the browser between full navigations.

Wiring Liquid data into a Vue Custom Element

The bridge is the json Liquid filter. Shopify renders the value server-side; Vue reads it as a prop on connectedCallback.

In your Liquid section file (sections/product-form.liquid):

<variant-picker
  data-product='{{ product | json | escape }}'
  data-selected='{{ product.selected_variant.id }}'
></variant-picker>

In your Vue component (src/components/VariantPicker.ce.vue):

<script setup>
import { computed } from 'vue'

const props = defineProps({
  product: { type: String, required: true },
  selected: { type: String, default: '' }
})

const parsed = computed(() => JSON.parse(props.product))
const variants = computed(() => parsed.value.variants)
</script>

<template>
  <div class="variant-picker">
    <button
      v-for="v in variants"
      :key="v.id"
      :aria-pressed="String(v.id) === selected"
      @click="$emit('variant-changed', v.id)"
    >
      {{ v.title }}
    </button>
  </div>
</template>

In your theme entry JS (assets/theme.js):

import { defineCustomElement } from 'vue'
import VariantPickerSFC from '../src/components/VariantPicker.ce.vue'

const VariantPicker = defineCustomElement(VariantPickerSFC)

if (!customElements.get('variant-picker')) {
  customElements.define('variant-picker', VariantPicker)
}

The if (!customElements.get(...)) guard is critical. Shopify sections can be rendered multiple times on the same page (featured products, related products), so the same script tag can execute more than once. Calling customElements.define() with an already-registered name throws a NotSupportedError that silently kills the component.

Note the .ce.vue file extension convention: Vite's Vue plugin treats files ending in .ce.vue differently, injecting styles into the component's shadow root rather than the document head. Since you want light DOM (no Shadow DOM) for Shopify themes, you can override this by passing shadowRoot: false as an option to defineCustomElement, or by simply not including a <style> block and managing CSS from your theme stylesheet as usual.

Avoiding Shadow DOM

This deserves its own section because it trips up almost every developer the first time.

defineCustomElement defaults to Shadow DOM, which creates a style boundary. Your theme's base.css won't reach inside it. The Theme Editor's section-hover outline won't render correctly. Shopify's own themes use Custom Elements with regular (light) DOM for exactly this reason.

To disable Shadow DOM in a Vue custom element:

const VariantPicker = defineCustomElement(VariantPickerSFC, {
  shadowRoot: false
})

The shadowRoot: false option was introduced in Vue 3.3. If you're on an older minor, you can achieve the same effect by defining renderRoot() on the custom element class after creation. But on Vue 3.5.x (the current stable as of July 2026), the option is the clean path.

The cart loop: Vue + Section Rendering API

State management in the cart is where most Liquid-plus-Vue implementations fall apart. Here's the pattern that holds up in production:

  1. Vue's mini-cart component calls POST /cart/add.js with a sections parameter.
  2. The Cart AJAX API mutates the cart and re-renders the requested Liquid sections server-side in the same response, returning them under response.sections.
  3. Vue swaps the inner HTML of the section wrapper from the server response, then updates its own reactive state for any purely client-side count display.

The bundled section rendering approach means you make one network request instead of two. The official Shopify Cart API supports bundled rendering on /cart/add, /cart/change, /cart/clear, and /cart/update.

// Inside your Vue mini-cart composable
async function addItem(variantId, quantity = 1) {
  const res = await fetch(window.Shopify.routes.root + 'cart/add.js', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      items: [{ id: variantId, quantity }],
      sections: 'cart-drawer,cart-icon-bubble'
    })
  })
  const data = await res.json()

  // Re-render Liquid sections from server HTML
  for (const [id, html] of Object.entries(data.sections ?? {})) {
    const el = document.getElementById(`shopify-section-${id}`)
    if (el) el.innerHTML = html
  }

  // Update Vue reactive state for count badge
  cartCount.value = data.items_count
}

This pattern is what Shopify's Horizon theme uses internally: section IDs are read from data- attributes on the rendered wrapper rather than hardcoded, so the cart drawer's id attribute (which Shopify prefixes with shopify-section-) is resolved at runtime rather than assumed.

Build setup: Vite + Shopify CLI

The tooling decision matters. The recommended setup for 2026 Shopify theme development with Vue 3:

  • Shopify CLI 3.x as the dev server and theme push layer.
  • Vite (@vitejs/plugin-vue) as the asset bundler, outputting a single theme.js and theme.css to the assets/ folder.
  • isCustomElement compiler option in vite.config.js to tell Vue's compiler that your custom element tags are intentional, suppressing "unknown component" warnings.
// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [
    vue({
      template: {
        compilerOptions: {
          isCustomElement: (tag) =>
            ['variant-picker', 'mini-cart', 'quantity-stepper'].includes(tag)
        }
      }
    })
  ],
  build: {
    outDir: 'assets',
    rollupOptions: {
      input: 'src/theme.js',
      output: { entryFileNames: 'theme.js', assetFileNames: 'theme.css' }
    }
  }
})

Run vite build --watch in one terminal and shopify theme dev in a second. Vite rebuilds assets/theme.js on every save; the CLI pushes the changed asset to the dev preview. No webpack, no theme-kit.

State management: skip Pinia, reach for composables

For most Shopify theme use cases, Pinia is overkill. Custom Elements are isolated by definition. What you need instead:

  • Composables (useCart, useVariant) that hold ref state and expose mutation methods.
  • provide / inject if a parent custom element needs to share state with a nested one (for example, a product form coordinating with a sticky add-to-cart bar on scroll).
  • A shared singleton module (src/state/cart.js that exports a single reactive cart object) when two completely separate custom elements both need to read cart count.

The singleton module pattern is the right answer for cart count badges. Import the same module in both your mini-cart and cart-icon-bubble custom element definitions. Because Vite bundles everything into a single theme.js, the module is evaluated once and the reactive object is genuinely shared.

Component boundaries: what to Vue, what to leave in Liquid

Wrap in a Vue Custom Element when:

  • The UI requires local state that changes without a navigation (variant selection, quantity updates, toggling options).
  • You need optimistic UI (show the cart count increment before the server confirms).
  • The component has complex event orchestration (image zoom synced to variant change).

Keep in plain Liquid when:

  • The content is static per page load (product description, collection header, footer).
  • Merchant customization via the Theme Editor is the main requirement (use sections and blocks, not Vue).
  • The component maps directly to a Shopify section schema setting.

On a catalog I maintain with over 10,000 active SKUs, only five component types are Vue custom elements: variant picker, quantity stepper, mini-cart drawer, wishlist toggle, and a comparison tray. Everything else is Liquid. The theme's Lighthouse performance scores stayed in the green because Vue is not imported until DOMContentLoaded, and all five components are small enough that the total gzipped Vue 3 runtime (33.9 KB min+gzip) is worth the interactivity gain.

Approach comparison

ApproachTheme Editor supportBundle size impactComplexityWhen to use
CDN <script> + createApp per islandPartial (breaks section reorder)Medium (per-island Vue instance)LowPrototyping only
Root createApp over full pageBreaks editorOne Vue runtimeLowNever on live themes
defineCustomElement + light DOMFullOne Vue runtime sharedMediumProduction: interactive islands
Hydrogen (headless)N/AFull React/Remix bundleHighFull custom storefronts

Verifying the integration works

Three checks before you ship:

  1. Theme Editor check. Open the editor, drag a section that contains your custom element to a new position. Confirm the element re-initializes and does not throw a NotSupportedError in the console. If it does, your customElements.get() guard is missing.
  2. Hydration timing check. In DevTools, throttle to Slow 3G. Confirm the Liquid-rendered HTML is visible before JavaScript executes. If your custom element's <template> renders a loading state while connectedCallback fires, visitors on slow connections see something, not a blank slot.
  3. Section rendering round-trip check. In the Network tab, add an item to cart and confirm only one request goes to /cart/add.js. If you see a second request to /cart.js, your bundled section rendering is not passing the sections parameter correctly.

For ongoing Liquid syntax questions that come up while building section schema and data serialization, my Shopify Liquid reference glossary covers filters, objects, and tag behavior in one place.

For stores that would rather hand this off, Lintel is one studio that offers Shopify theme development.

shopifyliquidvue.jstheme developmentjavascriptfrontend

Frequently asked questions

Can I use the full Vue 3 Composition API inside a defineCustomElement component?

Yes. defineCustomElement wraps a normal Vue component, so you have full access to ref, reactive, computed, watch, provide, inject, and lifecycle hooks. The only difference is that styles are injected into the shadow root by default, which you will want to disable for Shopify themes by passing shadowRoot: false.

Do I need Pinia for state management in a Shopify theme using Vue 3?

Not for most theme use cases. A shared singleton reactive module exported from a single file and imported by all custom element definitions achieves cart-count sharing without Pinia's overhead. Reserve Pinia for cases where you have genuinely complex cross-component state that spans many custom elements.

Will Vue 3 custom elements break Shopify's Theme Editor live preview?

Only if you use Shadow DOM or mount Vue over the entire document. Custom Elements with light DOM (shadowRoot: false) are fully compatible with the Theme Editor because Shopify can still access and reorder the DOM nodes normally.

Six years of Shopify, on one page.

Roles, projects, stack and certifications are on the resume.

Resume Download PDF LinkedIn