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
defineCustomElementconverts any.ce.vueSingle File Component into a native Custom Element you can drop into any Liquid template. - Liquid serializes Shopify data to JSON strings via the
jsonfilter; Vue reads those strings aspropsor 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
| Layer | Technology | Responsibility |
|---|---|---|
| Data & markup | Shopify Liquid | Server-render products, collections, cart, metafields |
| Reactive islands | Vue 3 Custom Elements | Variant pickers, mini-cart, quantity steppers, image zoom |
| Section updates | Section Rendering API | Re-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:
- Vue's mini-cart component calls
POST /cart/add.jswith asectionsparameter. - 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. - 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 singletheme.jsandtheme.cssto theassets/folder. isCustomElementcompiler option invite.config.jsto 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 holdrefstate and expose mutation methods. provide/injectif 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.jsthat exports a singlereactivecart 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
| Approach | Theme Editor support | Bundle size impact | Complexity | When to use |
|---|---|---|---|---|
CDN <script> + createApp per island | Partial (breaks section reorder) | Medium (per-island Vue instance) | Low | Prototyping only |
Root createApp over full page | Breaks editor | One Vue runtime | Low | Never on live themes |
defineCustomElement + light DOM | Full | One Vue runtime shared | Medium | Production: interactive islands |
| Hydrogen (headless) | N/A | Full React/Remix bundle | High | Full custom storefronts |
Verifying the integration works
Three checks before you ship:
- 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
NotSupportedErrorin the console. If it does, yourcustomElements.get()guard is missing. - 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 whileconnectedCallbackfires, visitors on slow connections see something, not a blank slot. - 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 thesectionsparameter 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.
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.