GTM for Payload/NextJS

Published 18 June, 2025

Website
Hero

Centralising analytics and third-party script management is essential when developing with Payload CMS and Next.js. Embedding tracking scripts directly within the application can create compliance risks, affect site performance, and complicate maintenance. Leveraging Google Tag Manager (GTM) as the centralised platform for analytics, including GA4, Meta, TikTok, Reddit, Pinterest, and Microsoft Clarity, ensures a clear separation from application logic, enhances operational control, and streamlines consent management.

This setup enables GTM debugging and reduces the likelihood of ad blockers detecting the tracking script. It provides precise control and tracking of analytics using Consent Mode v2.


Google tools

Managing analytics with Payload CMS and Next.js requires coordinated use of several platforms. Each tool has a specific function within the data collection and reporting process, with Google Tag Manager (GTM) as the integration point.

Google Tag Manager (GTM)
GTM manages and injects analytics and tracking scripts into your application via a single container. This streamlines deployment, configuration, and consent management.

Google Analytics 4 (GA4)
GA4 records user interactions on the site, such as page views and events, and sends this data to Google’s analytics platform for analysis and reporting.

Google Search Console (GSC)
GSC provides insights on how the site is indexed and performs in Google Search. It tracks search queries, crawl activity, and indexing status.

GTM functions as the primary tool for deploying and managing GA4 tracking scripts. All custom events and analytics logic are loaded via GTM, ensuring a controlled and centralised implementation.

While GSC does not inject scripts or send real-time interaction data to GA4, integration between GSC and GA4 enables consolidated reporting within the GA4 dashboard. This allows search performance data from GSC to be accessible alongside behavioural analytics from GA4, supporting unified analysis and reporting.


Tag manager

Google Tag Manager (GTM) should be the only platform for deploying analytics and marketing scripts within Payload CMS and Next.js projects. Avoid embedding software development kits (SDKs) or tracking snippets directly into the application frontend or Payload administrative interface. All analytics requirements should be managed through GTM by configuring the necessary tags and establishing custom triggers for specific user actions, route changes, or content types.

For scenarios requiring dynamic contextual data, such as user identifiers or page classifications, utilise the GTM dataLayer. Push structured data objects to the dataLayer to enable GTM to activate tags with precise contextual information.

This method preserves the integrity of the frontend codebase by separating tracking concerns and preventing unnecessary code proliferation related to analytics logic.


Script control

Utilising Google Tag Manager (GTM) to manage the loading of third-party scripts grants precise control over resource execution and placement. GTM enables deferred execution, allowing tags to be triggered after specific user interactions or upon rendering defined views. Scripts can be selectively activated based on route changes or component-level criteria, ensuring they run only where necessary. Additionally, domain restrictions can be implemented to block tags from sending data to unapproved endpoints, enhancing data governance.

This centralised approach supports the development of lightweight application bundles, keeping the Next.js frontend clear of embedded marketing code. The resulting reduction in code weight and execution overhead delivers tangible improvements in application performance, particularly for server-side rendered and hydration-sensitive routes.


Integration

For compliant analytics integration in a Next.js application, tracking scripts and essential utilities are best managed within a centralised provider component, rather than injected directly into the main layout file (layout.tsx). By including the <GTM /> component within <Providers>, tracking and consent logic are consolidated, promoting maintainability and ensuring consistent regulatory compliance across the application.

This structure enables all global utilities, such as Google Tag Manager, consent management, and future providers, to be organised in a single location. Additional context providers, such as user context or marketing integrations, can be incorporated without disrupting the main layout structure.

the layout.tsx

import { Providers } from "@/context"
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html className={`antialiased`} lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
)
}


@/context/index.tsx

import { GTM } from "@/context/tag/google"
// import { Hubspot } from "@/context/tag/hubspot"
import { CookieConsent } from "@/components/cookies"
// import { UserProvider } from "@/context/users"
export const Providers = ({ children }: { children: React.ReactNode }) => {
return (
<>
{/* <UserProvider> */}
<GTM />
{children}
<CookieConsent />
{/* <Hubspot /> */}
{/* </UserProvider> */}
</>
)
}


This pattern ensures that the Google Tag Manager script is conditionally loaded per user consent and provides a consistent, persistent consent state to all components via the application context. Integration with additional providers, such as user context, can further enrich tracking data as requirements evolve, with the flexibility to adjust provider scope as needed.

GTM

The <GTM /> component manages Google Tag Manager initialisation and maintains alignment between application cookie consent status and GTM consent mode. This approach ensures that analytics scripts operate only according to user preferences, supporting privacy compliance and regulatory requirements.

@/context/tag/google.tsx

"use client"
import { useEffect } from "react"
import Script from "next/script"
import { useCookieStore } from "@/store/useCookieStore"
// Optional: Use Next.js official integration or a custom worker
// import { GoogleTagManager } from "@next/third-parties/google";
export const GTM = () => {
const { preferences, hasConsented, acceptCookies } = useCookieStore()
const nonce =
typeof document !== "undefined"
? document.querySelector("script[nonce]")?.getAttribute("nonce") || ""
: ""
useEffect(() => {
if (typeof window === "undefined" || hasConsented === null) return
function updateConsent() {
if (typeof window.gtag === "function") {
window.gtag("consent", "update", {
functionality_storage: "granted",
security_storage: "granted",
analytics_storage: preferences.analytics ? "granted" : "denied",
ad_storage: preferences.marketing ? "granted" : "denied",
ad_user_data: preferences.userData ? "granted" : "denied",
ad_personalization: preferences.adPersonalization
? "granted"
: "denied",
personalization_storage: preferences.contentPersonalization
? "granted"
: "denied",
})
}
}
updateConsent()
if (!window.gtag) {
const scriptCheck = setInterval(() => {
if (window.gtag) {
updateConsent()
clearInterval(scriptCheck)
}
}, 50)
return () => clearInterval(scriptCheck)
}
}, [hasConsented, acceptCookies])
return (
<>
<Script id="gtm-inline-config" strategy="beforeInteractive">{`
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('consent', 'default', {
'functionality_storage': 'denied',
'security_storage': 'denied',
'analytics_storage': 'denied',
'ad_storage': 'denied',
'ad_user_data': 'denied',
'ad_personalization': 'denied',
'personalization_storage': 'denied'
});
gtag('set', 'url_passthrough', true);
window.dataLayer.push({
'gtm.start': new Date().getTime(),
event: 'gtm.js'
});
`}</Script>
<Script
id="gtm-init"
src="https://tag.rubixstudios.com.au/trigger.js" // Adjust to your worker
strategy="afterInteractive"
nonce={nonce}
/>
{/* https://tag.domain.com/trigger.js script ensures loading gtm as first-party */}
{/* Example using official Next.js GTM integration: */}
{/*
<GoogleTagManager
gtmId="GTM-XXXXXXX"
gtmScriptUrl="https://tag.domain.com/trigger.js"
/>
*/}
</>
)
}


This configuration loads GTM from a first-party-controlled endpoint and synchronises consent dynamically, ensuring that analytics activity always reflects the user's stated preferences.

Consent must be set before any Google script is executed, as required by Consent Mode v2. The url_passthrough parameter enables conversion tracking even when consent is denied by passing attribution data through the URL.

The <CookieConsent /> component presents a persistent, accessible cookie consent banner to users, enabling them to accept or decline the use of cookies. It integrates with the application's cookie store, capturing and persisting the user's consent decision across the application session. On acceptance or rejection, the relevant consent state is updated, which can be used to control the activation of analytics, advertising, or personalisation features.

The component provides clear user feedback, accessible navigation, and links to the privacy policy for transparency. Consent choices are communicated throughout the application, ensuring regulatory compliance and respecting user preferences in downstream analytics and marketing integrations.

@/components/cookies/index.tsx

"use client"
import { useEffect, useState } from "react"
import { HiOutlineCog } from "react-icons/hi"
import { LuCookie } from "react-icons/lu"
import Link from "next/link"
import { cn } from "@/lib/utils"
import { getClientSideURL } from "@/utils/getURL"
import { useCookieStore } from "@/store/useCookieStore"
import { Button } from "@/components/ui/button"
import { Preferences } from "@/components/cookies/preferences"
export function CookieConsent() {
const { hasConsented, acceptCookies, declineCookies, preferences } =
useCookieStore()
const [isOpen, setIsOpen] = useState(false)
const [hide, setHide] = useState(false)
const [showPreferences, setShowPreferences] = useState(false)
const accept = () => {
setIsOpen(false)
setTimeout(() => {
setHide(true)
}, 700)
acceptCookies({
...preferences,
analytics: true,
marketing: true,
userData: true,
adPersonalization: true,
contentPersonalization: true,
})
}
const decline = () => {
setIsOpen(false)
setTimeout(() => {
setHide(true)
}, 700)
declineCookies()
}
const manage = () => {
setIsOpen(false)
setTimeout(() => {
setHide(true)
setShowPreferences(true)
}, 700)
}
useEffect(() => {
if (hasConsented === null) {
setIsOpen(true)
setHide(false)
} else {
setIsOpen(false)
setHide(true)
}
}, [hasConsented])
return (
<>
<Preferences open={showPreferences} onOpenChange={setShowPreferences} />
<div
role="dialog"
aria-modal="true"
aria-live="assertive"
aria-labelledby="cookie-title-small"
aria-describedby="cookie-description-small"
className={cn(
"fixed bottom-4 left-1/2 z-[200] w-full -translate-x-1/2 transform p-4 sm:mx-0 sm:max-w-md sm:p-0",
!isOpen
? "scale-95 opacity-0 transition-[opacity,transform]"
: "scale-100 opacity-100 transition-[opacity,transform]",
hide && "hidden"
)}
>
<div className="dark:bg-card bg-background border-border m-0 rounded-lg border shadow-lg sm:m-3">
<header className="flex items-center justify-between p-3">
<p
id="cookie-title-small"
className="text-base font-medium"
role="heading"
aria-level={2}
>
We use cookies
</p>
<LuCookie
className="h-4 w-4 sm:h-[1.2rem] sm:w-[1.2rem]"
aria-hidden="true"
/>
</header>
<main className="-mt-3 space-y-1 p-3" id="cookie-description-small">
<p className="text-muted-foreground text-left text-xs">
We use cookies to improve your experience, analyse traffic, and deliver
personalised content and ads. Essential cookies for security and core
functionality are always enabled. By clicking &quot;Accept&quot;, you consent
to our use of additional cookies.
</p>
<p className="text-muted-foreground text-left text-xs">
For more information, see our{" "}
<Link
href={`${getClientSideURL()}/legal/privacy-policy`}
className="text-primary relative before:absolute before:bottom-0 before:left-0 before:h-[1px] before:w-full before:origin-right before:scale-x-100 before:bg-current before:transition-transform before:duration-500 hover:before:scale-x-0"
>
privacy policy
</Link>
.
</p>
</main>
<footer className="mt-2 flex flex-col items-center gap-2 border-t p-3 sm:flex-row">
<Button
onClick={manage}
variant="outline"
size="icon"
className="h-8 w-full cursor-pointer text-xs transition-all duration-300 sm:h-9 sm:w-9"
aria-label="Manage cookie consent"
>
<HiOutlineCog className="h-4 w-4" />
</Button>
<Button
onClick={decline}
className="h-8 w-full flex-1 cursor-pointer text-xs transition-all duration-300 sm:h-9"
aria-label="Decline cookie consent"
>
Decline
</Button>
<Button
onClick={accept}
className="h-8 w-full flex-1 cursor-pointer text-xs transition-all duration-300 sm:h-9"
aria-label="Accept cookie consent"
>
Accept
</Button>
</footer>
</div>
</div>
</>
)
}


Preferences

@/components/cookies/preferences.tsx

"use client"
import Link from "next/link"
import { useCookieStore } from "@/store/useCookieStore"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Switch } from "@/components/ui/switch"
export function Preferences({
open,
onOpenChange,
}: {
open: boolean
onOpenChange: (open: boolean) => void
}) {
const { preferences, setPreference, acceptCookies, declineCookies } = useCookieStore()
return (
<Dialog open={open} modal={true}>
<DialogContent>
<DialogHeader>
<DialogTitle>Preferences</DialogTitle>
</DialogHeader>
<div className="text-muted-foreground space-y-3 text-sm">
<div className="flex flex-row items-center justify-between gap-2">
<div>
<p className="text-foreground font-semibold">Function</p>
<p className="text-xs">
Required for core site features such as language settings, login
status, and user preferences.
</p>
</div>
<Switch
id="functional"
checked={preferences.functional}
disabled
aria-label="Enable functional cookies"
/>
</div>
<div className="flex flex-row items-center justify-between gap-2">
<div>
<p className="text-foreground font-semibold">Security</p>
<p className="text-xs">
Protect against security threats and ensure user session
integrity.
</p>
</div>
<Switch
id="security"
checked={preferences.security}
disabled
aria-label="Enable security cookies"
/>
</div>
<div className="flex flex-row items-center justify-between gap-2">
<div>
<p className="text-foreground font-semibold">Analytics</p>
<p className="text-xs">
Help us understand how visitors interact with the website using
aggregated data.
</p>
</div>
<Switch
id="analytics"
checked={preferences.analytics}
onCheckedChange={(checked) => setPreference("analytics", checked)}
className="cursor-pointer"
aria-label="Enable analytics cookies"
/>
</div>
<div className="flex flex-row items-center justify-between gap-2">
<div>
<p className="text-foreground font-semibold">Marketing</p>
<p className="text-xs">
Store data to serve ads that are more relevant to users.
</p>
</div>
<Switch
id="marketing"
checked={preferences.marketing}
onCheckedChange={(checked) => setPreference("marketing", checked)}
className="cursor-pointer"
aria-label="Enable marketing cookies"
/>
</div>
<div className="flex flex-row items-center justify-between gap-2">
<div>
<p className="text-foreground font-semibold">User</p>
<p className="text-xs">
Used to collect identifiable user data for targeted advertising.
</p>
</div>
<Switch
id="user-data"
checked={preferences.userData}
onCheckedChange={(checked) => setPreference("userData", checked)}
className="cursor-pointer"
aria-label="Enable user data cookies"
/>
</div>
<div className="flex flex-row items-center justify-between gap-2">
<div>
<p className="text-foreground font-semibold">Personalise</p>
<p className="text-xs">
Enables personalised ads based on browsing and usage history.
</p>
</div>
<Switch
id="ad-personalization"
checked={preferences.adPersonalization}
onCheckedChange={(checked) =>
setPreference("adPersonalization", checked)
}
className="cursor-pointer"
aria-label="Enable ad personalization cookies"
/>
</div>
<div className="flex flex-row items-center justify-between gap-2">
<div>
<p className="text-foreground font-semibold">Preference</p>
<p className="text-xs">
Adjusts content based on individual user behavior and
preferences.
</p>
</div>
<Switch
id="content-personalization"
checked={preferences.contentPersonalization}
onCheckedChange={(checked) =>
setPreference("contentPersonalization", checked)
}
className="cursor-pointer"
aria-label="Enable content personalization cookies"
/>
</div>
</div>
<div className="text-muted-foreground mt-2 text-xs">
<p>
By saving your preferences, you consent to the use of cookies as
described in our{" "}
<Link
href="/legal/privacy-policy"
className="text-primary underline hover:no-underline"
aria-label="Privacy Policy"
>
Privacy Policy
</Link>
. You can change your preferences at any time by clicking the
&quot;Cookie settings&quot; link in the footer.
</p>
</div>
<div className="flex justify-end gap-2">
<Button
onClick={() => {
declineCookies()
onOpenChange(false)
}}
className="cursor-pointer rounded-3xl text-xs uppercase transition-all duration-300"
aria-label="Decline cookie preferences"
>
Decline
</Button>
<Button
onClick={() => {
acceptCookies(preferences)
onOpenChange(false)
}}
className="cursor-pointer rounded-3xl text-xs uppercase transition-all duration-300"
aria-label="Accept cookie preferences"
>
Accept
</Button>
</div>
</DialogContent>
</Dialog>
)
}


Store

The useCookieStore hook manages the user's cookie consent state across the application using Zustand with persistence middleware. This store tracks whether the user has accepted or declined cookies and synchronises consent status with browser storage mechanisms for compliance and session persistence.

Upon acceptance, the consent state is written to browser cookies and localStorage, enabling long-term recognition of user preferences. If declined, the choice is stored in sessionStorage for the current session. The store's state is rehydrated on load to reflect any previously stored preferences, directly controlling the display of consent banners, analytics or marketing scripts.

This approach centralises consent management, ensuring a consistent user experience and regulatory alignment across all application components.

Zustand

To use Zustand for state management, add it to your project’s dependencies with your preferred package manager.

Using npm:

npm install zustand


Using pnpm:

pnpm add zustand


After installation, Zustand can be imported and used directly in your application code.

Store

@/store/useCookieStore.tsx

import { create } from "zustand"
import { persist } from "zustand/middleware"
interface CookiePreferences {
functional: boolean
security: boolean
analytics: boolean
marketing: boolean
userData: boolean
adPersonalization: boolean
contentPersonalization: boolean
}
interface CookieStore {
hasConsented: boolean | null
preferences: CookiePreferences
setPreference: (category: keyof CookiePreferences, value: boolean) => void
acceptCookies: (preferences?: Partial<CookiePreferences>) => void
declineCookies: () => void
}
const date = new Date()
date.setFullYear(date.getFullYear() + 1)
export const useCookieStore = create<CookieStore>()(
persist(
(set) => ({
hasConsented: null,
preferences: {
functional: true,
security: true,
analytics: false,
marketing: false,
userData: false,
adPersonalization: false,
contentPersonalization: false,
},
setPreference: (category: keyof CookiePreferences, value: boolean) => {
set((state) => ({
preferences: {
...state.preferences,
[category]:
category === "functional" || category === "security"
? true
: value,
},
}))
},
acceptCookies: (preferences?: Partial<CookiePreferences>) => {
try {
const newPreferences = {
functional: true,
security: true,
analytics: true,
marketing: true,
userData: true,
adPersonalization: true,
contentPersonalization: true,
...preferences,
}
document.cookie = `cookieConsent=true; expires=${date.toUTCString()}; path=/; SameSite=Lax; Secure`
localStorage.setItem("cookieConsent", "true")
localStorage.setItem(
"cookiePreferences",
JSON.stringify(newPreferences)
)
{/* Remove this sessionStorage if you intend to make decline persist */}
sessionStorage.removeItem("cookieConsent")
set({
hasConsented: true,
preferences: newPreferences,
})
} catch (error) {
console.error("Error setting cookie consent:", error)
set({ hasConsented: false })
}
},
declineCookies: () => {
try {
const declinedPreferences = {
functional: true,
security: true,
analytics: false,
marketing: false,
userData: false,
adPersonalization: false,
contentPersonalization: false,
}
{/* Remove this localStorage and sessionStorage if you intend to make decline persist */}
localStorage.removeItem("cookieConsent")
localStorage.removeItem("cookiePreferences")
sessionStorage.setItem("cookieConsent", "false")
{/* Remove commenting to persist declined cookieConsent */}
{/* localStorage.setItem("cookieConsent", "false") */}
{/* localStorage.setItem("cookiePreferences", JSON.stringify(declinedPreferences)) */}
set({
hasConsented: false,
preferences: declinedPreferences,
})
} catch (error) {
console.error("Error declining cookie consent:", error)
}
},
}),
{
name: "cookie-storage",
onRehydrateStorage: () => (state) => {
try {
const local = localStorage.getItem("cookieConsent")
const session = sessionStorage.getItem("cookieConsent")
if (session === "false") {
return state?.declineCookies()
}
if (local === "true") {
try {
const storedPreferences =
localStorage.getItem("cookiePreferences")
const preferences = storedPreferences
? JSON.parse(storedPreferences)
: undefined
return state?.acceptCookies(preferences)
} catch (error) {
console.error("Error loading cookie preferences:", error)
return state?.acceptCookies()
}
}
} catch (error) {
console.error("Error checking cookie consent:", error)
return state?.declineCookies()
}
},
}
)
)


Preference

The Preferences component manages user cookie consent configurations and must be rendered globally within the site's footer. This ensures users can access and modify their tracking preferences from any page. The CookieButton toggles the visibility of this modal interface, enabling compliance with privacy regulations such as GDPR and CCPA.

"use client"
import { useState } from "react"
import { Preferences } from "@/components/cookies/preferences"
export function CookieButton() {
const [showPreferences, setShowPreferences] = useState(false)
return (
<>
<button
onClick={() => setShowPreferences(true)}
className="mt-2 cursor-pointer "
>
Cookie settings
</button>
<Preferences open={showPreferences} onOpenChange={setShowPreferences} />
</>
)
}


Cloudflare

This Cloudflare Worker script proxies Google Tag Manager (gtm.js, ns.html) and Google Analytics (gtag/js) scripts through your domain, enabling these scripts to be served as first-party resources. This approach enhances privacy compliance, reduces the visibility of third-party network requests, and can help satisfy regulatory or client requirements regarding analytics loading.

How it works

  • Path control: Only requests to /trigger.js, /gtag/js, /robot.txt, or /ns.html are allowed and proxied. All other paths return a 404 response.
  • ID injection: The script appends your GTM or GA ID as needed for each proxied resource.
  • Caching: Results are cached on Cloudflare for 30 days (public, max-age=2592000, immutable), unless the gtm_debug parameter is present, improving performance and reliability.
  • Header: Adjusts response headers to ensure correct content type and optimal cache settings.
const GTM_ID = "GTM-XXXXXXX"; // Your Google Tag Manager ID
const GA_ID = "G-XXXXXXXXXX" // Your Google Analytics ID (Non Essential)
const GTM_BASE = "https://www.googletagmanager.com";
export default {
async fetch(request, ctx) {
const url = new URL(request.url);
const { pathname, searchParams } = url;
if (pathname === "/robots.txt") {
return new Response(
`User-agent: *\nDisallow:\n`,
{ status: 200, headers: { "Content-Type": "text/plain" } }
);
}
if (pathname === "/trigger.js" && !searchParams.has("id")) {
searchParams.set("id", GTM_ID);
}
const allowedPaths = ["/trigger.js", "/gtag/js", "/ns.html", "/robots.txt"];
const isDebug = searchParams.has("gtm_debug");
if (!allowedPaths.includes(pathname) && !isDebug) {
return new Response("Not found", { status: 404 });
}
if (!isDebug) {
const allowedReferers = [
"https://youdomain.com", // Adjust to match your domain
"https://tag.yourdomain.com", // Adjust to match your subdomain
];
const referer = request.headers.get("Referer") || "";
if (
referer &&
!allowedReferers.some(d => referer.startsWith(d))
) {
return new Response("Forbidden: Invalid Referer", { status: 403 });
}
const ua = request.headers.get("User-Agent") || "";
if (ua.toLowerCase().includes("chrome-lighthouse")) {
return new Response("", {
status: 200,
headers: {
"Content-Type": "application/javascript",
"Cache-Control": "public, max-age=31536000, immutable"
}
});
}
}
let upstreamPath = pathname === "/trigger.js" ? "/gtm.js" : pathname;
const upstreamUrl = new URL(`${GTM_BASE}${upstreamPath}`);
for (const [key, value] of searchParams.entries()) {
upstreamUrl.searchParams.set(key, value);
}
if ((upstreamPath === "/gtm.js" || upstreamPath === "/ns.html") && !upstreamUrl.searchParams.has("id")) {
upstreamUrl.searchParams.set("id", GTM_ID);
}
if (upstreamPath === "/gtag/js" && !upstreamUrl.searchParams.has("id")) {
upstreamUrl.searchParams.set("id", GA_ID);
}
const cache = caches.default;
const cacheKey = new Request(upstreamUrl.toString(), request);
if (!isDebug) {
const cached = await cache.match(cacheKey);
if (cached) return cached;
}
try {
const rsp = await fetch(upstreamUrl.toString(), {
headers: request.headers,
});
let body = await rsp.text();
const headers = new Headers(rsp.headers);
if (pathname === "/trigger.js") {
const nonce = request.headers.get("X-CSP-Nonce") || "";
if (nonce) {
body = body
.replace(
/(\.createElement\(['"]script['"]\))/g,
`.createElement("script");n.setAttribute("nonce", "${nonce}")`
)
.replace(
/(n\s*=\s*document\.createElement\(['"]script['"]\);)/g,
`$1\nn.setAttribute("nonce", "${nonce}");`
);
}
}
if (!isDebug) {
headers.delete("Vary");
headers.set(
"Content-Type",
pathname.endsWith(".js") || pathname === "/trigger.js"
? "application/javascript"
: "text/html; charset=utf-8"
);
headers.set("Cache-Control", "public, max-age=2592000, immutable");
}
const modified = new Response(body, {
status: rsp.status,
statusText: rsp.statusText,
headers,
});
if (!isDebug && rsp.ok) {
ctx.waitUntil(cache.put(cacheKey, modified.clone()));
}
return modified;
} catch {
return new Response("Upstream fetch failed", { status: 502 });
}
},
};


Workers

Access workers in Cloudflare

Navigate to your account dashboard and go to:

Compute (Workers) → Workers & Pages → Get started

Interface for getting started with Cloudflare Workers, showing options to select a template, import a repository, or start with Hello World.

Create a new worker

Click “Start with Hello World!”, name the worker (e.g., tag), and click Deploy.

Cloudflare interface for creating a new Worker named 'tag' with default Hello World script visible in the editor.

Replace the default code

Once the editor loads, replace the default "Hello World" code with your proxy logic.

Code editor view of a Cloudflare Worker with proxy logic implemented to securely forward GTM and GA scripts using environment variables.

Deploy the worker

Click Deploy in the top right corner to publish the latest version of your worker.


Set domain routes

Go to the InitialisationSettings tab of your Worker and define your public routes (e.g., https://tag.domain.com/gtag/js).

  • Use exact path routes for /gtag/js, /trigger.js, and /ns.html.
  • Make sure the workers.dev subdomain is disabled if not in use.
Cloudflare dashboard showing domain and route configuration for a Worker with custom paths to gtag.js, ns.html, and gtm.js under tag.rx0.com.au.

/gtm.js has been renamed to /trigger.js to reduce the likelihood of ad blockers detecting and blocking the tracking script.

Google Tag

To run Google Analytics via GTM with full support for Consent Mode (v2), you need a minimal configuration that includes:

Enable consent overview

In your GTM container settings:

  • Go to Admin > Container Settings
  • Enable “Consent Overview (BETA)”

This enables Consent Mode UI for managing ad_storage, analytics_storage, and others.

Google Tag Manager container settings with consent overview enabled for the rubix_studios account.

Create Google Tag (GA4)

In your workspace:

  • Add a Google Tag (GA4)
  • Configure the Tag ID (your GA4 measurement ID)
  • Enable Consent Settings with built-in checks:
    • ad_storage
    • ad_personalization
    • ad_user_data
    • analytics_storage
  • Set the trigger to:
    • Consent Initialisation - All Pages

This ensures the tag initialises only after consent decisions are made.

Google Tag configuration in GTM for GA4 with built-in consent checks enabled and firing on Consent Initialization.

Publish your changes

  • Click Submit in the top right corner.
  • Select “Publish and Create Version”
  • Add a descriptive name and version summary (optional but recommended).
  • Confirm the “All tags have been configured for consent” message.
  • Click Publish.
Google Tag Manager submission panel showing the publish and create version option with consent-ready Google Tag changes listed.

Centralising analytics and script management in Payload CMS and Next.js via Google Tag Manager (GTM) establishes a clear boundary between application logic and tracking infrastructure. This structured approach ensures regulatory compliance through consent synchronisation and first-party script hosting and significantly enhances site performance by reducing script bloat and preventing unnecessary execution.

Integrating providers such as <GTM /> and <CookieConsent /> within a unified context architecture, developers maintain consistent consent propagation and efficient analytics control. Further, leveraging a Cloudflare Worker to serve GTM and GA assets from a first-party domain improves trust, data control, and alignment with data privacy standards.