Essentials

Analytics

Send Shopify headless analytics from your Nuxt storefront

Shopify collects analytics for headless storefronts the same way it does for themes, powering the reports and live view in your admin. This module wires that up for you: drop-in components and a composable cover product, collection, search, and cart views, add-to-cart, and consent.

Under the hood it uses Shopify's own @shopify/hydrogen-react to send events, so you get the exact payloads Shopify expects without reimplementing them.

Analytics uses functionality from @shopify/hydrogen-react. Install it before enabling analytics in the module:
Terminal
npm install @shopify/hydrogen-react

Getting started

Analytics is opt-in. Once @shopify/hydrogen-react is installed, you can enable it in your nuxt.config.ts:

nuxt.config.ts
export default defineNuxtConfig({
  shopify: {
    name: 'quickstart-abcd1234',

    clients: {
      storefront: {
        publicAccessToken: '...',
      },
    },

    analytics: true,
  },
})

Configuration

The analytics option accepts true, false (default), or an object with the following settings, all optional:

storefrontId
string
Storefront ID from your Headless or Hydrogen sales channel. Attributes events to a specific storefront; without it, events fall back to the default storefront (0).
domain
string
Domain events are sent from. Defaults to <name>.myshopify.com.
shopId
string
Shop ID as gid://shopify/Shop/<id>. The automatic shop lookup is only skipped when shopId, currency and language are all set.
currency
string
Currency code (e.g. USD). Resolved from the Storefront API when omitted. Only the default market, see Buyer market for multi-market shops.
language
string
Language code (e.g. EN). Resolved from the Storefront API when omitted. Only the default market, see Buyer market for multi-market shops.
autoPageView
boolean
Report a page_viewed event on the initial load and on every client-side navigation. Defaults to true; set it to false to publish page views yourself.
consent
object
Customer Privacy (consent) options, see Consent below.

Tracking views

Page views are automatic — there is no component to place. The module reports one page_viewed event per URL, on the initial load and on every client-side navigation. Reporting waits until the shop has resolved, so the first view carries the shop identifiers and still reaches subscribers registered after the module. Views are keyed on path and query, so jumping to an anchor on the same page does not report a second view.

Views that Shopify understands (product, collection, search, cart) have a renderless component each. The product, collection, and search components also re-report when the item they describe changes (the product, the collection, or the search term), which covers client-side navigation between two products. ShopifyCartView reports once per mount.

~/app/pages/products/[handle].vue
<template>
  <!-- your product page -->

  <ShopifyProductView
    :data="{
      products: [{
        id: product.id,
        title: product.title,
        vendor: product.vendor,
        price: product.selectedOrFirstAvailableVariant.price.amount,
        variantId: product.selectedOrFirstAvailableVariant.id,
      }],
    }"
  />
</template>

The other view components follow the same shape:

<template>
  <ShopifyCollectionView :data="{ collection: { id: collection.id, handle: collection.handle } }" />

  <ShopifySearchView :data="{ searchTerm: query }" />

  <ShopifyCartView :data="{ cart }" />

  <ShopifyCustomView event="newsletter_signup" :data="{ source: 'footer' }" />
</template>

ShopifyCustomView prefixes the event name with custom_, so event="newsletter_signup" is published as custom_newsletter_signup (subscribe to that prefixed name). Custom events are only delivered to your own subscribers, never sent to Shopify.

Analytics composable

To publish events manually, use the useShopifyAnalytics() composable. It is safe to call anywhere, including on the server. When analytics is disabled, it returns a no-op.

const analytics = useShopifyAnalytics()

analytics.publish('product_viewed', {
  products: [/* ... */]
})

const off = analytics.subscribe('product_viewed', (payload) => {
  // payload is typed to the event
})

analytics.canTrack() // whether the visitor consented to analytics

Buyer market

Events carry the currency and language of the market they happened in. By default that is your shop's default market. If you want to change which market the buyer is in so it re-resolves the shop with Shopify's @inContext directive, call setShopContext with a country and/or language:

~/app/plugins/analytics-market.client.ts
export default defineNuxtPlugin(() => {
  const analytics = useShopifyAnalytics()
  const { locale } = useI18n()

  watch(locale, () => analytics.setShopContext({
    country: 'CA',
    language: 'EN'
  }), { immediate: true })
})

Both values are optional and are upper-cased for you, so an i18n locale like en-us is accepted where a LanguageCode is expected. Pass Shopify's enum codes, not a locale string: country is a CountryCode (CA, DE) and language is a LanguageCode (EN, PT_BR). Each market is resolved once and cached, and a market Shopify does not recognize falls back to whatever was resolved before it, so a bad code degrades the reported market instead of silencing analytics.

currency and language in your configuration remain the default for the first resolve; setting both still skips the initial lookup entirely.

Cart events

Cart events are derived, so you never publish them yourself. Keep the cart in sync after every mutation and the module works out what changed by diffing the previous cart against the new one:

const analytics = useShopifyAnalytics()

const { data } = await addToCartMutation()

analytics.setCart({
  ...data.cart,
  lines: flattenConnection(data.cart.lines),
})

That publishes cart_updated, plus a product_added_to_cart or product_removed_from_cart for every line whose quantity changed. Only product_added_to_cart reaches Shopify; the other two are for your own subscribers.

The cart needs id and updatedAt, and each line needs merchandise.product.id and merchandise.product.vendor, or the event cannot be attributed. Updates carrying an updatedAt that was already reported are ignored, so a reload never re-reports a cart.

Forwarding to other tools

Because everything runs through one bus, subscribe is also how you fan events out to a third-party provider such as GA4 or Meta:

~/app/plugins/ga.client.ts
export default defineNuxtPlugin(() => {
  const analytics = useShopifyAnalytics()

  analytics.subscribe('product_added_to_cart', ({currentLine }) => {
    gtag('event', 'add_to_cart', { /* map from currentLine */ })
  })
})

Every event is gated on Shopify's Customer Privacy API. In regions that require opt-in, such as the EU, nothing is sent and no tracking cookies are set until the visitor consents. Elsewhere, Shopify's API may permit tracking by default. Either way, consent is dictated by Shopify, not the module.

Point consent at your checkout domain so it is shared between your storefront and Shopify's checkout:

nuxt.config.ts
export default defineNuxtConfig({
  shopify: {
    analytics: {
      storefrontId: '1234567',
      consent: {
        checkoutDomain: 'checkout.my-shop.com',
      },
    },
  },
})
checkoutDomain
string
Your checkout domain. Defaults to the store domain.
storefrontAccessToken
string
Public storefront token used by the consent API. Defaults to the storefront client's publicAccessToken.
withPrivacyBanner
boolean
Load Shopify's own cookie banner. Defaults to false. Leave it off to build your own.
country
string
Two-letter country code passed to Shopify's privacy banner.
language
string
Locale passed to Shopify's privacy banner.

Record the visitor's choice through the composable, or from your own banner:

const analytics = useShopifyAnalytics()

analytics.setTrackingConsent({
  analytics: true,
  marketing: true,
  preferences: true,
  sale_of_data: true,
})

ShopifyPrivacyBanner is a renderless component that holds accept, decline, setConsent, and canTrack through its slot, you can style the banner however you like:

<template>
  <ShopifyPrivacyBanner v-slot="{ accept, decline }">
    <div class="banner">
      <p>We use cookies to measure our store.</p>

      <button @click="accept">
        Accept
      </button>

      <button @click="decline">
        Decline
      </button>
    </div>
  </ShopifyPrivacyBanner>
</template>

If you set withPrivacyBanner: true, Shopify renders its own banner and you can skip this component entirely.

Copyright © 2026