Compiling ideas into code…
Wiring up APIs and UI…
Sit back for a moment while the site gets ready.
ShopifyThemes

Vue.js ShopifyTheme Development

Beyond Vanilla JavaScript in Shopify Themes

Every Shopify theme starts simple: a few event listeners, a fetch() call to the Cart API, some DOM manipulation to show and hide elements. Then scope creeps in. The collection page needs reactive filtering. The cart drawer needs to sync its count with the header badge. The product form's variant logic grows to handle three option types, inventory tracking, and dynamic pricing. Before long, your theme.js file is 2,000 lines of interleaved event handlers, DOM queries, and implicit state scattered across data- attributes.

This is where vanilla JavaScript hits its maintainability wall. Not because it can't do the job, it can, but because it provides no structure for organizing reactive state, no isolation between components, and no way to reason about which UI elements update when data changes. You end up building a framework yourself, one addEventListener at a time, except your framework has no documentation, no tests, and no new hire has any hope of understanding it.

Vue 3's Composition API solves this without the overhead of a full SPA. You write small, focused composables, useFilters(), useCart(), useProduct(), each encapsulating a single concern. These composables are plain JavaScript functions that use ref(), computed(), and watch() for reactivity. They're testable outside the DOM, portable between components, and explicit about their dependencies. The Composition API eliminates the Options API's tendency to scatter related logic across data, methods, computed, and watch blocks, a problem that gets worse as Shopify-specific complexity grows.


The Delimiter Problem

Before writing a single Vue component for Shopify, you need to address the elephant in the room: both Liquid and Vue use {{ }} for template interpolation. Without intervention, Liquid's server-side renderer will try to process Vue's client-side expressions, producing either errors or garbled output.

Three solutions exist, in order of recommendation:

1. Custom delimiters in Vite config (recommended). Configure Vue's template compiler to use different delimiters, typically ${ }$ or [[ ]]. This change is made once and applies to all Single File Components:

vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import shopify from 'vite-plugin-shopify'

export default defineConfig({
  plugins: [
    vue({
      template: {
        compilerOptions: {
          delimiters: ['${', '}$']
        }
      }
    }),
    shopify()
  ]
})

2. Pre-compiled SFCs (the real answer). If all your Vue templates are in .vue Single File Components and you never use inline template strings, the delimiter conflict doesn't exist in practice. SFC templates are compiled to render functions at build time, {{ }} in a .vue file is never sent to the browser and never seen by Liquid. This is the approach we'll use in this article.

3. The v-pre directive. Tells Vue to skip compilation of an element and its children. Useful if you have Liquid expressions inside a Vue mount point that you want Liquid to process server-side.

In practice, option 2 eliminates the problem entirely. When you use .vue SFCs compiled by Vite, Vue's {{ }} syntax lives in JavaScript render functions, never in the HTML that Liquid processes. The "delimiter conflict" is primarily a concern when using inline templates or the runtime compiler.


The Case Study: Reactive Collection Filtering

Here's the business requirement: build a collection page where customers can filter products by type, vendor, and price range, change the sort order, and load more products, all without full page reloads. Filters should deep-link to the URL so customers can share filtered views. A header cart badge should update instantly when a product card's "Quick Add" button is clicked.

This is a natural fit for Vue because the interaction is complex enough that vanilla JavaScript becomes unmaintainable: multiple filter facets need to combine, the product grid needs to re-render, pagination state needs to reset when filters change, and cart state needs to propagate to a separate component in the header. Alpine.js could handle simpler filtering, but when you need composables, computed properties, and shared state across isolated DOM islands, Vue's Composition API delivers cleaner architecture.

The architecture uses three Vue islands mounted onto Liquid-rendered containers, sharing state through a single Pinia instance:

  • FilterSidebar, Faceted filters (product type, vendor, price range) with reactive counts
  • ProductGrid, Displays filtered products with "Quick Add" buttons and "Load More" pagination
  • CartBadge, Header icon showing live item count

Setting Up the Vite Build Pipeline

Vue in a Shopify theme requires a build step, there's no CDN shortcut like Alpine.js. The good news is that vite-plugin-shopify handles most of the integration automatically: it discovers entry points, generates the correct asset paths for development (HMR) and production (CDN), and produces a Liquid snippet you include in your layout.

Start by initializing the project and installing dependencies:

Terminal
npm init -y
npm install vue pinia
npm install -D vite @vitejs/plugin-vue vite-plugin-shopify

Create the directory structure. Entry points live in frontend/entrypoints/, each file becomes a separate bundle that you can load on specific pages:

your-theme/
├── frontend/
│   ├── entrypoints/
│   │   ├── collection.js      # Collection page islands
│   │   └── cart-badge.js      # Header cart badge (loaded globally)
│   ├── components/
│   │   ├── FilterSidebar.vue
│   │   ├── ProductGrid.vue
│   │   └── CartBadge.vue
│   ├── composables/
│   │   ├── useFilters.js
│   │   └── useCart.js
│   └── stores/
│       └── cart.js
├── sections/
├── snippets/
├── assets/
├── vite.config.js
└── package.json

The Vite configuration tells the Shopify plugin where to find entry points and how to compile Vue templates:

vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import shopify from 'vite-plugin-shopify'

export default defineConfig({
  plugins: [
    vue(),
    shopify({
      themeRoot: './',
      sourceCodeDir: 'frontend',
      entrypointsDir: 'frontend/entrypoints'
    })
  ],
  build: {
    emptyOutDir: false
  }
})

emptyOutDir: false is critical. Without it, Vite will delete everything in assets/ during production builds, including third-party app files, font files, and images managed by Shopify. This flag tells Vite to only overwrite files it generates.

Wire the output into your theme layout. The vite-plugin-shopify generates a vite-tag snippet that automatically switches between the Vite dev server (with HMR) and production CDN paths:

layout/theme.liquid
<head>
  {% render 'vite-tag' with 'cart-badge.js' %}
</head>
<body>
  {%- if template.name == 'collection' -%}
    {% render 'vite-tag' with 'collection.js' %}
  {%- endif -%}
</body>

Set up your package.json scripts to streamline the development and deployment workflow. Vite's production build applies tree-shaking, minification, and code splitting automatically, your deployed assets will be significantly smaller than the source:

package.json
{
  "name": "your-shopify-theme",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "dev:shopify": "shopify theme dev --store your-store.myshopify.com",
    "dev:all": "concurrently \"npm run dev\" \"npm run dev:shopify\"",
    "deploy": "vite build && shopify theme push"
  },
  "dependencies": {
    "vue": "^3.5.0",
    "pinia": "^2.3.0"
  },
  "devDependencies": {
    "vite": "^6.0.0",
    "@vitejs/plugin-vue": "^5.2.0",
    "vite-plugin-shopify": "^4.0.0",
    "concurrently": "^9.0.0"
  }
}

The scripts break down as follows:

  • npm run dev, Starts the Vite dev server with HMR on localhost:5173. File changes reflect instantly in the browser without a full reload
  • npm run build, Produces optimized production assets in assets/. Vite applies tree-shaking (unused Vue APIs are removed), minification (Terser or esbuild), and chunk splitting for shared dependencies
  • npm run dev:shopify, Starts the Shopify CLI theme dev server, which proxies your store and watches for Liquid file changes
  • npm run dev:all, Runs both dev servers in parallel using concurrently. This is the command you'll use most during development
  • npm run deploy, Builds production assets first, then pushes the entire theme (including the optimized bundles) to your Shopify store

Always run npm run build before deploying. The vite-tag snippet in your layout detects whether the Vite dev server is running. In production (no dev server), it falls back to the built assets in assets/. If you deploy without building, the snippet will reference files that don't exist.

During development, run both servers simultaneously:

Terminal
# Run both Vite HMR and Shopify theme dev in parallel
npm run dev:all

# Or run them in separate terminals:
# Terminal 1: Vite dev server (HMR)
npm run dev

# Terminal 2: Shopify theme dev server
npm run dev:shopify

One essential file is often overlooked: .shopifyignore. Without it, shopify theme push uploads your entire project, including node_modules/, source files, and build configuration, to Shopify's servers. Create this file in your theme root:

.shopifyignore
frontend/
node_modules/
package.json
package-lock.json
vite.config.js
.env
.gitignore

For production deployments where other developers or apps may have added files to the theme, use the --nodelete flag to prevent shopify theme push from removing files that exist remotely but not locally:

Terminal
# Safe deployment: build + push without deleting remote-only files
npm run build && shopify theme push --nodelete

Automating Builds with GitHub Actions

Manual builds work for solo development, but on a team, or when deploying from a staging branch, you want builds to happen automatically. A GitHub Actions workflow can install dependencies, run the Vite build, and commit the compiled assets back to the branch so that Shopify always receives production-ready files.

.github/workflows/build-assets.yml
name: Build assets with Vite

on:
  push:
    branches:
      - staging

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '22'

      - name: Install pnpm
        run: npm install -g pnpm

      - name: Install dependencies
        run: pnpm install

      - name: Build assets with Vite
        run: pnpm run build

      - name: Commit generated assets
        run: |
          git config --global user.name 'github-actions[bot]'
          git config --global user.email '41898282+github-actions[bot]@users.noreply.github.com'
          git add assets/
          git commit -m 'Auto-build: compiled Vite assets' || echo "No changes to commit"
          git push origin staging

The workflow triggers on every push to staging. After Vite compiles and tree-shakes your Vue components into optimized bundles in assets/, the action commits those built files back to the branch. This means your staging branch always contains production-ready assets, whoever connects it to Shopify (via GitHub integration or shopify theme push) gets the compiled output without needing Node.js locally.

Scope your git add to assets/ only. Adding everything (git add .) risks committing lock file changes or other artifacts. Since Vite outputs to assets/, targeting that folder keeps the commit clean. If your Vite build also generates a Liquid snippet (like the vite-tag), add snippets/vite-tag.liquid to the commit as well.

Multiple entry points? If your theme has separate Vite builds (e.g., a base theme build and an app-specific build), chain them in the build step: pnpm run build:base && pnpm run build:app. Each build produces its own chunk in assets/, and the single git add assets/ captures everything.


Step 1: Mounting Vue Islands into Liquid

The island pattern mounts independent Vue applications onto Liquid-rendered DOM nodes. Each island is self-contained, its own createApp() call, its own component tree, but all share a single Pinia instance for state coordination.

The Liquid template renders the structure and passes data through a JSON script tag:

sections/main-collection.liquid
<script type="application/json" id="collection-data">
  {
    "collectionHandle": {{ collection.handle | json }},
    "currentUrl": {{ collection.url | json }},
    "sortOptions": {{ collection.sort_options | json }},
    "currentSort": {{ collection.sort_by | default: collection.default_sort_by | json }},
    "filters": {{ collection.filters | json }},
    "products": {{ collection.products | json }},
    "hasNextPage": {{ paginate.next.is_link | json }},
    "nextPageUrl": {{ paginate.next.url | default: '' | json }}
  }
</script>

<div class="collection-layout">
  <aside id="vue-filter-sidebar">
    <!-- FilterSidebar mounts here -->
    <noscript>
      {%- for filter in collection.filters -%}
        <!-- Server-rendered filter fallback -->
      {%- endfor -%}
    </noscript>
  </aside>

  <main id="vue-product-grid">
    <!-- ProductGrid mounts here -->
    <noscript>
      {%- for product in collection.products -%}
        {% render 'product-card', product: product %}
      {%- endfor -%}
    </noscript>
  </main>
</div>

The <noscript> fallbacks are important. They ensure the collection page is functional and SEO-indexed even if JavaScript fails. Search engines see the server-rendered products and filters; Vue enhances the experience when JavaScript is available.

The entry point script reads the data, creates a shared Pinia instance, and mounts each island:

frontend/entrypoints/collection.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import FilterSidebar from '../components/FilterSidebar.vue'
import ProductGrid from '../components/ProductGrid.vue'

const pinia = createPinia()

const collectionData = JSON.parse(
  document.getElementById('collection-data').textContent
)

const filterEl = document.getElementById('vue-filter-sidebar')
if (filterEl) {
  const filterApp = createApp(FilterSidebar, {
    filters: collectionData.filters,
    collectionUrl: collectionData.currentUrl
  })
  filterApp.use(pinia)
  filterApp.mount(filterEl)
}

const gridEl = document.getElementById('vue-product-grid')
if (gridEl) {
  const gridApp = createApp(ProductGrid, {
    initialProducts: collectionData.products,
    currentSort: collectionData.currentSort,
    sortOptions: collectionData.sortOptions,
    hasNextPage: collectionData.hasNextPage,
    nextPageUrl: collectionData.nextPageUrl,
    collectionUrl: collectionData.currentUrl
  })
  gridApp.use(pinia)
  gridApp.mount(gridEl)
}

The critical detail: both apps call app.use(pinia) with the same Pinia instance. This means any store defined with defineStore() shares state across both islands. When the FilterSidebar updates filter selections, the ProductGrid reacts. When the ProductGrid's "Quick Add" button updates the cart store, the CartBadge in the header reflects it.


Step 2: Collection Filtering with Composables

The useFilters() composable encapsulates all filter logic: tracking selected values, building URL parameters, fetching filtered results via the Section Rendering API, and syncing filter state to the URL for deep linking.

frontend/composables/useFilters.js
import { ref, computed, watch } from 'vue'

export function useFilters(initialFilters, collectionUrl) {
  const filters = ref(initialFilters)
  const activeFilters = ref(buildInitialSelections(initialFilters))
  const isLoading = ref(false)

  function buildInitialSelections(filterList) {
    const selections = {}
    filterList.forEach(filter => {
      selections[filter.param_name] = filter.active_values
        ? filter.active_values.map(v => v.value)
        : []
    })
    return selections
  }

  const activeCount = computed(() =>
    Object.values(activeFilters.value)
      .reduce((sum, vals) => sum + vals.length, 0)
  )

  function toggleFilter(paramName, value) {
    const current = activeFilters.value[paramName] || []
    const idx = current.indexOf(value)
    if (idx === -1) {
      current.push(value)
    } else {
      current.splice(idx, 1)
    }
    activeFilters.value[paramName] = [...current]
  }

  function clearAll() {
    Object.keys(activeFilters.value).forEach(key => {
      activeFilters.value[key] = []
    })
  }

  function buildFilterUrl() {
    const params = new URLSearchParams()
    Object.entries(activeFilters.value).forEach(([param, values]) => {
      values.forEach(val => params.append(param, val))
    })
    return collectionUrl + (params.toString() ? '?' + params.toString() : '')
  }

  async function applyFilters() {
    isLoading.value = true
    const url = buildFilterUrl()

    window.history.pushState({}, '', url)

    try {
      const res = await fetch(url, {
        headers: { 'Accept': 'application/json' }
      })
      const html = await res.text()
      return html
    } finally {
      isLoading.value = false
    }
  }

  return {
    filters,
    activeFilters,
    activeCount,
    isLoading,
    toggleFilter,
    clearAll,
    applyFilters,
    buildFilterUrl
  }
}

The FilterSidebar component uses this composable and emits events when filters change:

frontend/components/FilterSidebar.vue
<script setup>
import { watch } from 'vue'
import { useFilters } from '../composables/useFilters'

const props = defineProps({
  filters: Array,
  collectionUrl: String
})

const emit = defineEmits(['filter-change'])

const {
  filters: filterList,
  activeFilters,
  activeCount,
  isLoading,
  toggleFilter,
  clearAll,
  applyFilters
} = useFilters(props.filters, props.collectionUrl)

watch(activeFilters, async () => {
  const html = await applyFilters()
  emit('filter-change', html)
}, { deep: true })
</script>

<template>
  <div class="filter-sidebar">
    <div class="filter-header">
      <h3>Filters</h3>
      <button
        v-if="activeCount > 0"
        @click="clearAll"
        class="filter-clear">
        Clear all (${ activeCount }$)
      </button>
    </div>

    <div
      v-for="filter in filterList"
      :key="filter.param_name"
      class="filter-group">
      <h4>${ filter.label }$</h4>
      <ul>
        <li v-for="value in filter.values" :key="value.value">
          <label class="filter-option">
            <input
              type="checkbox"
              :checked="activeFilters[filter.param_name]?.includes(value.value)"
              @change="toggleFilter(filter.param_name, value.value)"
              :disabled="isLoading">
            <span>${ value.label }$</span>
            <span class="filter-count">(${ value.count }$)</span>
          </label>
        </li>
      </ul>
    </div>
  </div>
</template>

Notice how the template uses ${ }$ delimiters instead of {{ }}. If you're using pre-compiled SFCs without custom delimiters (option 2 from earlier), Vue's standard {{ }} in the .vue file works fine because the template is compiled to a render function at build time and never reaches Liquid. The custom delimiters shown here are for teams that prefer the explicit visual distinction.


Step 3: Shared Cart State with Pinia

The cart store is where Pinia's multi-island state sharing shines. The product grid's "Quick Add" button, the cart drawer, and the header badge all reference the same store instance. When one island modifies the cart, every other island reacts.

frontend/stores/cart.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useCartStore = defineStore('cart', () => {
  const rootUrl = window.Shopify?.routes?.root || '/'
  const items = ref([])
  const itemCount = ref(0)
  const totalPrice = ref(0)
  const isLoading = ref(false)

  const isEmpty = computed(() => items.value.length === 0)
  const formattedTotal = computed(() =>
    formatMoney(totalPrice.value)
  )

  async function init() {
    const cart = await fetch(rootUrl + 'cart.js').then(r => r.json())
    syncState(cart)
  }

  function syncState(cart) {
    items.value = cart.items
    itemCount.value = cart.item_count
    totalPrice.value = cart.total_price
  }

  async function addItem(variantId, quantity = 1) {
    isLoading.value = true
    try {
      const res = await fetch(rootUrl + 'cart/add.js', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          items: [{ id: variantId, quantity }]
        })
      })

      if (!res.ok) {
        const err = await res.json()
        throw new Error(err.description || 'Could not add item')
      }

      await init()
    } catch (error) {
      console.error('Cart error:', error.message)
      throw error
    } finally {
      isLoading.value = false
    }
  }

  async function updateItem(key, quantity) {
    isLoading.value = true
    try {
      const cart = await fetch(rootUrl + 'cart/change.js', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ id: key, quantity })
      }).then(r => r.json())
      syncState(cart)
    } finally {
      isLoading.value = false
    }
  }

  function formatMoney(cents) {
    return (cents / 100).toLocaleString(undefined, {
      style: 'currency',
      currency: window.Shopify?.currency?.active || 'USD'
    })
  }

  init()

  return {
    items,
    itemCount,
    totalPrice,
    isLoading,
    isEmpty,
    formattedTotal,
    addItem,
    updateItem,
    formatMoney,
    init
  }
})

The store uses the Composition API syntax for defineStore(), a setup function returning refs and methods rather than the Options-style state/getters/actions pattern. This keeps it consistent with the composables and makes TypeScript inference better.

The CartBadge component in the header is a separate Vue island that mounts with the same Pinia instance:

frontend/entrypoints/cart-badge.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import CartBadge from '../components/CartBadge.vue'

const pinia = createPinia()

const el = document.getElementById('vue-cart-badge')
if (el) {
  const app = createApp(CartBadge)
  app.use(pinia)
  app.mount(el)
}

Important caveat for multi-page themes. Shopify themes with traditional page navigation (no AJAX routing) create fresh JavaScript contexts on each page load. The Pinia instance resets. If you need cart state to survive across page navigations, use Pinia's persistedstate plugin to sync with sessionStorage, or initialize the store from Liquid's {{ cart | json }} on every page load (which is what our init() call does).


Step 4: AJAX Pagination

The ProductGrid component combines filtering, sorting, and "Load More" pagination. When filters change, it replaces the product list. When the user clicks "Load More," it appends the next page's products.

frontend/components/ProductGrid.vue
<script setup>
import { ref } from 'vue'
import { useCartStore } from '../stores/cart'

const props = defineProps({
  initialProducts: Array,
  currentSort: String,
  sortOptions: Array,
  hasNextPage: Boolean,
  nextPageUrl: String,
  collectionUrl: String
})

const cart = useCartStore()
const products = ref([...props.initialProducts])
const sort = ref(props.currentSort)
const nextUrl = ref(props.nextPageUrl)
const hasMore = ref(props.hasNextPage)
const loadingMore = ref(false)
const addingToCart = ref(null)

async function loadMore() {
  if (!nextUrl.value || loadingMore.value) return
  loadingMore.value = true

  try {
    const res = await fetch(nextUrl.value)
    const html = await res.text()
    const parser = new DOMParser()
    const doc = parser.parseFromString(html, 'text/html')

    const dataEl = doc.getElementById('collection-data')
    if (dataEl) {
      const data = JSON.parse(dataEl.textContent)
      products.value.push(...data.products)
      hasMore.value = data.hasNextPage
      nextUrl.value = data.nextPageUrl
    }
  } finally {
    loadingMore.value = false
  }
}

async function changeSort(newSort) {
  sort.value = newSort
  const url = new URL(window.location)
  url.searchParams.set('sort_by', newSort)
  window.history.pushState({}, '', url)
  window.location.reload()
}

async function quickAdd(variantId) {
  addingToCart.value = variantId
  try {
    await cart.addItem(variantId)
  } finally {
    addingToCart.value = null
  }
}
</script>

<template>
  <div class="product-grid-wrapper">
    <div class="grid-header">
      <select @change="changeSort($event.target.value)" :value="sort">
        <option
          v-for="opt in sortOptions"
          :key="opt.value"
          :value="opt.value">
          ${ opt.name }$
        </option>
      </select>
    </div>

    <div class="product-grid">
      <article
        v-for="product in products"
        :key="product.id"
        class="product-card">
        <a :href="product.url">
          <img
            :src="product.featured_image"
            :alt="product.title"
            loading="lazy"
            width="400"
            height="400">
        </a>
        <h3><a :href="product.url">${ product.title }$</a></h3>
        <p class="product-price">${ cart.formatMoney(product.price) }$</p>
        <button
          @click="quickAdd(product.variants[0].id)"
          :disabled="addingToCart === product.variants[0].id"
          class="btn-quick-add">
          ${ addingToCart === product.variants[0].id ? 'Adding...' : 'Quick Add' }$
        </button>
      </article>
    </div>

    <button
      v-if="hasMore"
      @click="loadMore"
      :disabled="loadingMore"
      class="btn-load-more">
      ${ loadingMore ? 'Loading...' : 'Load More Products' }$
    </button>
  </div>
</template>

The "Load More" implementation fetches the next page's HTML, parses it for the embedded JSON data, and appends the new products to the reactive array. This avoids maintaining a separate API endpoint, it reuses Shopify's standard collection pagination URLs. When products are appended, Vue's reactivity system efficiently updates only the new DOM nodes.


Runtime vs Pre-compiled Templates

Vue ships in two builds: the full build (includes the template compiler, ~48KB gzipped) and the runtime-only build (~34KB gzipped). The difference is about 14KB, the weight of the in-browser template compiler.

When you use Vite with .vue Single File Components, every template is pre-compiled to JavaScript render functions at build time. The browser never needs to compile templates, so the runtime-only build is used automatically. This is Vite's default behavior, you don't need to configure anything.

You only need the full build if you're constructing Vue templates as runtime strings, for example, mounting Vue onto an element and using its innerHTML as a template. In a Shopify theme context, this is almost never necessary because your templates are in .vue files, and Liquid handles the server-rendered HTML.

The 14KB savings from pre-compilation is automatic with Vite. If you explicitly need the runtime compiler (uncommon), alias vue to vue/dist/vue.esm-bundler.js in your Vite config. But in practice, pre-compiled SFCs are the right choice for Shopify themes.


Edge Cases You Need to Handle

AJAX page transitions. If your theme uses AJAX navigation (barba.js, Swup), Vue apps mounted on the outgoing page are destroyed but not cleaned up. Always call app.unmount() before the page transitions away, then re-mount on the incoming page. Consider a centralized mount/unmount registry:

frontend/utils/island-manager.js
const mountedApps = []

export function mountIsland(component, selector, pinia, props = {}) {
  const el = document.querySelector(selector)
  if (!el) return null

  const app = createApp(component, props)
  app.use(pinia)
  app.mount(el)

  mountedApps.push({ app, selector })
  return app
}

export function unmountAll() {
  mountedApps.forEach(({ app }) => app.unmount())
  mountedApps.length = 0
}

Deep linking filter state to URL. Customers expect to share a filtered collection URL and see the same results. The useFilters() composable pushes filter params to window.history, and on initial load, reads from URLSearchParams to restore active filters. Make sure your Liquid template also respects these URL params for the server-rendered initial state.

Preserving third-party app assets. Shopify apps inject JavaScript and CSS files into the assets/ folder. Setting emptyOutDir: false in your Vite config prevents the build from deleting these files. Alternatively, use vite-plugin-shopify-clean which only removes Vite-generated files while preserving everything else.

Empty filter results. When a filter combination yields zero products, show a clear "No products match your filters" message with a "Clear all filters" button. Don't leave the grid silently empty.

HMR and Shopify preview. Vite's HMR works through a WebSocket connection. When using shopify theme dev, the preview is served through a proxy that may not forward WebSocket connections. Ensure the Vite dev server and the browser can communicate directly (typically localhost:5173).


Key Takeaways

  • Vue 3's Composition API brings structure to Shopify theme JavaScript. Composables like useFilters() and useCart() encapsulate reactive logic that vanilla JS scatters across event handlers and global variables
  • The delimiter conflict is largely a non-issue. Pre-compiled SFCs never expose Vue's {{ }} to Liquid. For extra safety, configure custom delimiters in your Vite config
  • Pinia makes multi-island state trivial. Share one Pinia instance across all createApp() calls, and every island gets the same reactive store. Cart state, filter state, and UI state all propagate automatically
  • vite-plugin-shopify handles the build pipeline. Entry point discovery, dev server HMR, production asset paths, and the vite-tag Liquid snippet, all automatic
  • Runtime-only build saves 14KB automatically. Vite pre-compiles SFC templates at build time. You never ship Vue's template compiler to the browser
  • Always set emptyOutDir: false. Without it, Vite will delete third-party app assets during production builds
  • Vue earns its ~34KB when you need composable logic and shared state. For simpler enhancement (accordions, toggles, basic cart drawers), Alpine.js at 4KB is the better tool

If your Shopify theme needs reactive collection filtering, shared cart state across components, or a structured JavaScript architecture that scales with your team, I can help you design and build it with Vue 3. Let's talk about what your theme actually needs.

Advanced Shopify Discount Solutions