Analytics Architecture on Commercetools
Commercetools is a headless commerce platform. It provides no frontend rendering, no HTML templates, and no server-side script injection points. The entire storefront is built in a frontend framework (Next.js, Nuxt, Gatsby, custom React/Vue) that consumes commercetools APIs. Analytics implementation lives entirely in the frontend layer, using commercetools API responses as data sources for data layer pushes and ecommerce events.
The API layer is the core of commercetools. Product data comes from the Product Projections API, cart state from the Cart API, and order confirmation from the Order API. Each API response contains the structured data needed for analytics events: product names, SKUs, prices, currencies, cart contents, and order totals. Your frontend code maps these API responses to GA4 ecommerce events.
Commercetools does not manage frontend JavaScript. There is no admin panel for injecting tracking scripts, no theme system with <head> injection, and no plugin marketplace for analytics. GTM, GA4, and any tracking pixel are installed and configured entirely within your frontend framework's build and deployment pipeline.
The Merchant Center is commercetools' admin interface for managing products, orders, and settings. It does not affect storefront rendering. Custom Applications in the Merchant Center can be built with React for internal tooling (e.g., analytics dashboards that query commercetools data), but they do not inject scripts into the storefront.
Sunrise is commercetools' reference storefront implementation. Sunrise SPA (Vue.js) and Sunrise Next.js provide starting points that include routing, product display, cart, and checkout. Analytics hooks are not included by default. You add them to Sunrise's component lifecycle.
Event sourcing in commercetools means every state change (cart created, line item added, order placed) generates an event. These events can be consumed via Subscriptions (webhooks to AWS SNS/SQS, Google Pub/Sub, or Azure Event Grid) for server-side analytics. This is useful for backend attribution and data warehouse pipelines but does not replace client-side tracking for user behavior analytics.
Installing Tracking Scripts
Next.js Frontend (App Router)
In a Next.js storefront consuming commercetools APIs, install GTM via the root layout:
// app/layout.tsx
import Script from 'next/script';
const GTM_ID = process.env.NEXT_PUBLIC_GTM_ID;
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<head>
{GTM_ID && (
<Script id="gtm" strategy="afterInteractive">
{`
(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;
j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;
f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','${GTM_ID}');
`}
</Script>
)}
</head>
<body>
{GTM_ID && (
<noscript>
<iframe
src={`https://www.googletagmanager.com/ns.html?id=${GTM_ID}`}
height="0"
width="0"
style={{ display: 'none', visibility: 'hidden' }}
/>
</noscript>
)}
{children}
</body>
</html>
);
}
Nuxt.js Frontend
For a Nuxt 3 storefront:
// nuxt.config.ts
export default defineNuxtConfig({
app: {
head: {
script: [
{
children: `(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;
j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;
f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','${process.env.NUXT_PUBLIC_GTM_ID}');`,
},
],
noscript: [
{
children: `<iframe src="https://www.googletagmanager.com/ns.html?id=${process.env.NUXT_PUBLIC_GTM_ID}" height="0" width="0" style="display:none;visibility:hidden"></iframe>`,
body: true,
},
],
},
},
});
Gatsby Frontend
// gatsby-ssr.tsx
import React from 'react';
export const setHeadComponents, setPreBodyComponents }) => {
const gtmId = process.env.GATSBY_GTM_ID;
if (!gtmId) return;
setHeadComponents([
<script
key="gtm"
dangerouslySetInnerHTML={{
__html: `(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;
j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;
f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','${gtmId}');`,
}}
/>,
]);
setPreBodyComponents([
<noscript key="gtm-noscript">
<iframe
src={`https://www.googletagmanager.com/ns.html?id=${gtmId}`}
height="0"
width="0"
style={{ display: 'none', visibility: 'hidden' }}
/>
</noscript>,
]);
};
Data Layer Implementation
Mapping Commercetools API Responses to Data Layer
Create a utility module that transforms commercetools API objects into GA4-compatible data layer events:
// lib/analytics.ts
interface CommerceToolsProduct {
id: string;
name: Record<string, string>;
masterVariant: {
sku: string;
prices: Array<{
value: { centAmount: number; currencyCode: string };
}>;
};
categories: Array<{ id: string }>;
}
interface DataLayerItem {
item_id: string;
item_name: string;
price: number;
item_category?: string;
}
export function mapProductToItem(
product: CommerceToolsProduct,
locale: string,
categoryNames: Record<string, string> = {}
): DataLayerItem {
const price = product.masterVariant.prices[0];
return {
item_id: product.masterVariant.sku || product.id,
item_name: product.name[locale] || product.name['en'],
price: price ? price.value.centAmount / 100 : 0,
item_category: product.categories[0]
? categoryNames[product.categories[0].id] || ''
: '',
};
}
export function pushEvent(eventName: string, data: Record<string, unknown>) {
if (typeof window === 'undefined') return;
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({ ecommerce: null }); // Clear previous ecommerce data
window.dataLayer.push({
event: eventName,
...data,
});
}
Product Listing Page
When rendering a product listing (category page, search results):
// components/ProductList.tsx
import { useEffect } from 'react';
import { mapProductToItem, pushEvent } from '@/lib/analytics';
export function ProductList({ products, categoryName, locale }) {
useEffect(() => {
const items = products.map((p) => mapProductToItem(p, locale));
pushEvent('view_item_list', {
ecommerce: {
item_list_id: categoryName.toLowerCase().replace(/\s/g, '_'),
item_list_name: categoryName,
items: items.map((item, index) => ({
...item,
index,
})),
},
});
}, [products, categoryName, locale]);
return (
<div>
{products.map((product, index) => (
<ProductCard
key={product.id}
product={product}
index={index}
listName={categoryName}
locale={locale}
/>
))}
</div>
);
}
Product Detail Page
// pages/product/[slug].tsx (or app/product/[slug]/page.tsx)
import { useEffect } from 'react';
import { mapProductToItem, pushEvent } from '@/lib/analytics';
export default function ProductDetail({ product, locale }) {
useEffect(() => {
const item = mapProductToItem(product, locale);
const price = product.masterVariant.prices[0];
const currency = price?.value.currencyCode || 'USD';
pushEvent('view_item', {
ecommerce: {
currency,
value: item.price,
items: [item],
},
});
}, [product, locale]);
// ... render product detail
}
SPA Route Changes
Commercetools storefronts are SPAs. Track virtual pageviews on route changes:
// Next.js App Router
'use client';
import { usePathname } from 'next/navigation';
import { useEffect } from 'react';
export function PageViewTracker() {
const pathname = usePathname();
useEffect(() => {
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
event: 'virtual_pageview',
page_path: pathname,
page_title: document.title,
});
}, [pathname]);
return null;
}
// Nuxt 3
// plugins/analytics.client.ts
export default defineNuxtPlugin((nuxtApp) => {
const router = useRouter();
router.afterEach((to) => {
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
event: 'virtual_pageview',
page_path: to.fullPath,
page_title: document.title,
});
});
});
E-commerce Tracking
Add to Cart
When the user adds a product to the cart via the commercetools Cart API:
// hooks/useCart.ts
import { pushEvent, mapProductToItem } from '@/lib/analytics';
export function useCart() {
const addToCart = async (product, variant, quantity = 1) => {
// Call commercetools Cart API
const response = await fetch('/api/cart/add', {
method: 'POST',
body: JSON.stringify({
productId: product.id,
variantId: variant.id,
quantity,
}),
});
if (response.ok) {
const price = variant.prices[0];
const currency = price?.value.currencyCode || 'USD';
const unitPrice = price ? price.value.centAmount / 100 : 0;
pushEvent('add_to_cart', {
ecommerce: {
currency,
value: unitPrice * quantity,
items: [{
item_id: variant.sku || product.id,
item_name: product.name,
price: unitPrice,
quantity,
}],
},
});
}
return response;
};
const removeFromCart = async (lineItem) => {
const response = await fetch('/api/cart/remove', {
method: 'POST',
body: JSON.stringify({ lineItemId: lineItem.id }),
});
if (response.ok) {
pushEvent('remove_from_cart', {
ecommerce: {
currency: lineItem.price.value.currencyCode,
value: (lineItem.price.value.centAmount / 100) * lineItem.quantity,
items: [{
item_id: lineItem.variant.sku,
item_name: lineItem.name,
price: lineItem.price.value.centAmount / 100,
quantity: lineItem.quantity,
}],
},
});
}
return response;
};
return { addToCart, removeFromCart };
}
Checkout Steps
Track checkout progression:
// components/Checkout.tsx
import { pushEvent } from '@/lib/analytics';
function mapCartToItems(cart) {
return cart.lineItems.map((li) => ({
item_id: li.variant.sku,
item_name: li.name,
price: li.price.value.centAmount / 100,
quantity: li.quantity,
}));
}
// When user starts checkout
function onBeginCheckout(cart) {
pushEvent('begin_checkout', {
ecommerce: {
currency: cart.totalPrice.currencyCode,
value: cart.totalPrice.centAmount / 100,
items: mapCartToItems(cart),
},
});
}
// When user enters shipping info
function onAddShippingInfo(cart, shippingMethod) {
pushEvent('add_shipping_info', {
ecommerce: {
currency: cart.totalPrice.currencyCode,
value: cart.totalPrice.centAmount / 100,
shipping_tier: shippingMethod.name,
items: mapCartToItems(cart),
},
});
}
// When user enters payment info
function onAddPaymentInfo(cart, paymentMethod) {
pushEvent('add_payment_info', {
ecommerce: {
currency: cart.totalPrice.currencyCode,
value: cart.totalPrice.centAmount / 100,
payment_type: paymentMethod.type,
items: mapCartToItems(cart),
},
});
}
Purchase Confirmation
After the commercetools Order API returns a successful order:
// pages/order-confirmation.tsx
import { useEffect } from 'react';
import { pushEvent } from '@/lib/analytics';
export default function OrderConfirmation({ order }) {
useEffect(() => {
if (!order) return;
// Prevent duplicate fires on page refresh
const firedKey = `purchase_fired_${order.id}`;
if (sessionStorage.getItem(firedKey)) return;
const items = order.lineItems.map((li) => ({
item_id: li.variant.sku,
item_name: li.name[Object.keys(li.name)[0]],
price: li.price.value.centAmount / 100,
quantity: li.quantity,
}));
pushEvent('purchase', {
ecommerce: {
transaction_id: order.orderNumber || order.id,
value: order.totalPrice.centAmount / 100,
tax: order.taxedPrice
? order.taxedPrice.totalTax.centAmount / 100
: 0,
shipping: order.shippingInfo
? order.shippingInfo.price.centAmount / 100
: 0,
currency: order.totalPrice.currencyCode,
items,
},
});
sessionStorage.setItem(firedKey, 'true');
}, [order]);
// ... render confirmation
}
Server-Side Analytics via Subscriptions
For backend analytics (e.g., sending data to a warehouse), use commercetools Subscriptions:
// Create a subscription via the commercetools API
{
"destination": {
"type": "GoogleCloudPubSub",
"projectId": "my-project",
"topic": "commerce-events"
},
"messages": [
{ "resourceTypeId": "order", "types": ["OrderCreated"] },
{ "resourceTypeId": "cart", "types": ["CartCreated"] }
]
}
Process these events in a Cloud Function or Lambda to send Measurement Protocol hits to GA4 for server-side attribution.
Common Issues
| Issue | Cause | Fix |
|---|---|---|
Ecommerce events fire with centAmount instead of decimal prices |
Commercetools stores prices in minor units (cents); raw API values were passed to data layer | Always divide centAmount by 100 before pushing to the data layer |
| Virtual pageviews fire twice on navigation | Both the router listener and a component-level useEffect push pageview events |
Use a single centralized pageview tracker (router plugin or layout component), not both |
| Product data is missing locale-specific names | Product names in commercetools are localized objects ({ "en": "...", "de": "..." }), not strings |
Always access product.name[locale] with a fallback: product.name[locale] || product.name['en'] |
| Data layer pushes contain stale ecommerce object | GA4 ecommerce events stack; a previous ecommerce object persists if not cleared |
Push { ecommerce: null } before every ecommerce event to reset the ecommerce object |
| SSR-rendered pages include data layer pushes that cause hydration mismatch | Next.js server-rendered HTML includes inline scripts that differ from client-side hydration | Gate data layer pushes with typeof window !== 'undefined' checks, or use useEffect which only runs client-side |
| Cart state is stale after browser back navigation | Browser bfcache restores the previous page state including old cart data | Listen for pageshow event and refresh cart state from the API when event.persisted is true |
| Subscription events arrive out of order | Commercetools does not guarantee message ordering in Pub/Sub/SNS | Process events idempotently using the sequenceNumber field; deduplicate by resource ID and version |
| Multi-store setup tracks all stores together | Single GTM container used across all storefronts | Use environment variables per storefront deployment to set different GTM container IDs, or use a GTM lookup table based on hostname |
Platform-Specific Considerations
Commercetools has no frontend. This is the most important distinction. Every tracking decision is a frontend framework decision, not a commercetools decision. The commercetools API is your data source, but the rendering, script injection, and event handling are entirely your frontend's responsibility.
Price representation. All commercetools prices use centAmount (integer) and currencyCode. A price of $19.99 is stored as { centAmount: 1999, currencyCode: "USD" }. Always divide by 100 (or the currency's fraction digits) before sending to analytics. Use Intl.NumberFormat or a utility function, not manual division, for currencies with non-standard decimal places (e.g., JPY has 0 decimal places).
Product variants. Commercetools products have a masterVariant and optional variants array. Each variant has its own SKU, prices, and attributes. When tracking product views, use the selected variant's SKU and price, not the master variant's, to ensure analytics data matches what the user sees.
Sunrise starter kits. If using Sunrise (Vue.js SPA or Next.js), analytics hooks are not included. Add them to the existing component structure. Sunrise uses composables (Vue) or hooks (React) for commercetools API calls. Wrap or extend these with analytics pushes rather than adding tracking inline in every component.
Custom Applications (Merchant Center). Custom Applications run inside the Merchant Center admin UI and are built with React + the @commercetools-frontend/application-shell. They are for internal tooling only and do not affect the storefront. Use them to build internal analytics dashboards that query commercetools APIs, not for customer-facing tracking.
Multi-channel and multi-store. Commercetools supports multiple channels (online, in-store, wholesale) and stores (regions, brands). The channel and store fields on cart and order objects should be included in your data layer so GA4 can segment by sales channel. Map these to custom dimensions.
GraphQL vs REST. Commercetools offers both REST and GraphQL APIs. The data structure in responses differs slightly. Ensure your analytics mapping functions handle both response shapes if your frontend uses a mix of endpoints, or standardize on one API style.