Analytics Architecture on Prismic
Prismic is a headless CMS that delivers content through REST and GraphQL APIs. Analytics tracking lives entirely in the frontend framework (Next.js, Nuxt, SvelteKit, etc.), not in Prismic itself. The CMS provides structured content through its Slice-based system, which maps directly to frontend components.
Key architecture points for analytics:
- Prismic Client fetches content via API. The response includes document metadata (type, tags, timestamps, locale) that feeds data layers
- SliceMachine generates typed components for each Slice. Analytics hooks attach at the Slice component level for granular content tracking
- Route Resolver maps Prismic documents to frontend URLs. Route changes trigger virtual page view events in SPAs
- Preview mode uses a preview session cookie. Filter preview traffic from production analytics to avoid skewed data
- Localization is built into the document model. Each locale variant is a separate document with its own UID, requiring locale-aware tracking
Since Prismic sites are SPAs, traditional page-load-based tracking does not work. Every route transition requires an explicit analytics event.
Installing Scripts in Your Frontend
Next.js with Prismic (App Router)
Prismic's official Next.js integration (@prismicio/next) works with the App Router. Add tracking scripts in the root layout:
// app/layout.tsx
import Script from 'next/script';
import { PrismicPreview } from '@prismicio/next';
import { repositoryName } from '@/prismicio';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<head>
<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-XXXXXX');`}
</Script>
</head>
<body>
{children}
<PrismicPreview repositoryName={repositoryName} />
</body>
</html>
);
}
Nuxt 3 with Prismic
<!-- nuxt.config.ts -->
<script setup>
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@nuxtjs/prismic'],
prismic: { endpoint: 'your-repo-name' },
app: {
head: {
script: [
{ src: 'https://www.googletagmanager.com/gtag/js?id=G-XXXXXX', async: true },
{ innerHTML: `window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}gtag('js',new Date());gtag('config','G-XXXXXX');` }
]
}
}
});
</script>
Data Layer from Prismic Documents
Build data layer pushes from the Prismic document response. The client returns structured metadata for every document:
// components/PrismicAnalytics.tsx
'use client';
import { useEffect } from 'react';
import { PrismicDocument } from '@prismicio/client';
interface Props {
document: PrismicDocument;
}
export function PrismicAnalytics({ document }: Props) {
useEffect(() => {
if (!document) return;
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
event: 'prismic_page_view',
content_type: document.type,
content_id: document.id,
content_uid: document.uid,
content_tags: document.tags.join(','),
content_lang: document.lang,
first_published: document.first_publication_date,
last_published: document.last_publication_date,
alternate_languages: document.alternate_languages
?.map(alt => alt.lang).join(',') || ''
});
}, [document.id]);
return null;
}
Use it in page components:
// app/[uid]/page.tsx
import { createClient } from '@/prismicio';
import { PrismicAnalytics } from '@/components/PrismicAnalytics';
export default async function Page({ params }: { params: { uid: string } }) {
const client = createClient();
const page = await client.getByUID('page', params.uid);
return (
<>
<PrismicAnalytics document={page} />
{/* page content */}
</>
);
}
Slice-Level Component Tracking
Prismic's Slice system lets you track engagement at the component level. Each Slice renders as a distinct section, and you can attach intersection observers for visibility tracking:
// slices/CallToAction/index.tsx
'use client';
import { useRef, useEffect } from 'react';
import { SliceComponentProps } from '@prismicio/react';
import { Content } from '@prismicio/client';
type Props = SliceComponentProps<Content.CallToActionSlice>;
export default function CallToAction({ slice }: Props) {
const ref = useRef<HTMLElement>(null);
useEffect(() => {
if (!ref.current) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
window.dataLayer?.push({
event: 'slice_view',
slice_type: slice.slice_type,
slice_label: slice.slice_label || 'default',
slice_variation: slice.variation
});
observer.disconnect();
}
},
{ threshold: 0.5 }
);
observer.observe(ref.current);
return () => observer.disconnect();
}, []);
return (
<section ref={ref} data-slice-type={slice.slice_type}>
{/* slice content */}
</section>
);
}
This fires a slice_view event when a CTA Slice becomes 50% visible, giving you engagement data at the content block level.
SPA Route Change Tracking
Since Prismic sites use client-side routing, track virtual page views on route transitions:
// components/RouteAnalytics.tsx
'use client';
import { usePathname, useSearchParams } from 'next/navigation';
import { useEffect, useRef } from 'react';
export function RouteAnalytics() {
const pathname = usePathname();
const searchParams = useSearchParams();
const isFirstRender = useRef(true);
useEffect(() => {
// Skip initial render (handled by server-side page view)
if (isFirstRender.current) {
isFirstRender.current = false;
return;
}
window.dataLayer?.push({
event: 'virtual_page_view',
page_path: pathname,
page_search: searchParams.toString()
});
}, [pathname, searchParams]);
return null;
}
Add this component to your root layout alongside the GTM script.
Preview Mode Filtering
Prismic's preview mode injects a session cookie (io.prismic.preview) and loads draft content. Filter preview sessions from analytics to keep production data clean:
// components/PrismicAnalytics.tsx (updated)
'use client';
import { useEffect } from 'react';
export function PrismicAnalytics({ document }) {
useEffect(() => {
// Skip tracking in preview mode
if (document.cookie?.includes('io.prismic.preview')) return;
if (typeof window !== 'undefined' && document.referrer?.includes('prismic.io')) return;
window.dataLayer?.push({
event: 'prismic_page_view',
// ...document metadata
});
}, [document.id]);
return null;
}
Alternatively, set a GTM variable that checks for the preview cookie and use it as a blocking trigger on all tags.
Common Errors
| Error | Cause | Fix |
|---|---|---|
| Duplicate page views on navigation | Both route change listener and page component fire events | Skip first render in route listener with a ref flag |
| Document data undefined in analytics | Async fetch not resolved before component mounts | Push data layer in useEffect with document dependency |
| Preview traffic in production analytics | Preview cookie not filtered | Check for io.prismic.preview cookie before pushing events |
| Slice tracking fires for every re-render | IntersectionObserver not disconnected | Call observer.disconnect() after first intersection |
| Wrong language tracked | Using document.lang returns Prismic locale code (en-us) |
Map Prismic locale codes to your analytics language values |
| Tags array causes GTM errors | GTM expects string, not array | Join tags with .join(',') before pushing to data layer |
| Stale data after content update | ISR/SSG serving cached page with old metadata | Set appropriate revalidate interval or use on-demand ISR |
| Missing alternate language data | Document has no translations configured | Check alternate_languages.length > 0 before accessing |
Performance Considerations
- Prismic Client caching: The
@prismicio/clientsupports built-in caching. UsefetchOptions: { next: { revalidate: 60 } }in Next.js to avoid redundant API calls - Static generation: Use
generateStaticParamsto pre-render Prismic pages at build time, reducing API calls and improving TTFB - Script loading: Use
strategy="afterInteractive"on Next.js Script components to avoid blocking the initial paint - Slice lazy loading: Use
React.lazy()or dynamic imports for heavy Slice components below the fold - API response size: Use Prismic's
graphQueryparameter to fetch only the fields you need, reducing payload size