How to Add Google Analytics (GA4) to a Next.js 14 Website with Opt-in?
My recent X post on how to add Google Analytics to a Next.js site with an opt-in banner got a lot of traction. Since there is interest, I've made detailed explanation with code to copy below.
The GitHub repository with a minimum working example is linked here. And credits to Ryan Gaudion whose Post provided a good basis for the solution below.Layout File
The layout file is where the Google Analytics and Cookie Banner components are added. Notice the suspense wrapper around the
Google Analytics component. This is necessary because the component uses useSearchParams
which is a client side function.
Without a Suspense
boundary the entire layout page will be rendered on the client-side. 1
import React, { Suspense } from 'react';
import GoogleAnalytics from '@/components/google-analytics';
import CookieBanner from '@/components/cookie-banner';
export default function RootLayout({ children }) {
return (
<html lang="en" >
<Suspense fallback={null}>
<GoogleAnalytics GA_MEASUREMENT_ID='G-1234567890' />
</Suspense>
<body className='container'>
{children}
<CookieBanner />
</body>
</html>
)
}
Google Analytics Component
The GoogleAnalytics
component takes a GA_MEASUREMENT_ID
as a prop and uses the useEffect
hook to update Google Analytics with the current URL whenever the pathname or search parameters change.
The component also includes two script tags that load the "Google Analytics" script and set up the initial configuration, including setting default consent for analytics storage to "denied".
'use client';
import Script from 'next/script'
import { usePathname, useSearchParams } from 'next/navigation'
import { useEffect } from "react";
export default function GoogleAnalytics({ GA_MEASUREMENT_ID }: { GA_MEASUREMENT_ID: string }) {
const pathname = usePathname()
const searchParams = useSearchParams()
useEffect(() => {
const url = pathname + searchParams.toString();
if (typeof window !== 'undefined' && window.gtag) {
window.gtag('config', GA_MEASUREMENT_ID, {
page_path: url,
});
}
}, [pathname, searchParams, GA_MEASUREMENT_ID]);
// Script is added to the head of the document. To Begin, consent is denied.
return (
<>
<Script strategy="afterInteractive"
src={`https://www.googletagmanager.com/gtag/js?id=${GA_MEASUREMENT_ID}`} />
<Script id='google-analytics' strategy="afterInteractive"
dangerouslySetInnerHTML={{
__html: `
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('consent', 'default', {
'analytics_storage': 'denied'
});
gtag('config', '${GA_MEASUREMENT_ID}', {
page_path: window.location.pathname,
});
`,
}}
/>
</>
)
}
Cookie Banner Component
The CookieBanner
component displays a banner for cookie consent. It uses the useState
and useEffect
hooks to manage the cookie consent state
and update the local storage and Google Analytics consent status when the user accepts or declines the cookie banner.
"use client";
import { useState, useEffect } from "react";
import { getLocalStorage, setLocalStorage } from "@/lib/storage-helper";
// CookieBanner component that displays a banner for cookie consent.
export default function CookieBanner() {
const [cookieConsent, setCookieConsent] = useState(null);
const [isLoading, setIsLoading] = useState(true);
// Retrieve cookie consent status from local storage on component mount
useEffect(() => {
const storedCookieConsent = getLocalStorage("cookie_consent", null);
console.log("Cookie Consent retrieved from storage: ", storedCookieConsent);
setCookieConsent(storedCookieConsent);
setIsLoading(false);
}, []);
// Update local storage and Google Analytics consent status when cookieConsent changes
useEffect(() => {
if (cookieConsent !== null) {
setLocalStorage("cookie_consent", cookieConsent);
}
const newValue = cookieConsent ? "granted" : "denied";
if (typeof window !== "undefined" && window.gtag) {
window.gtag("consent", "update", {
analytics_storage: newValue,
});
}
}, [cookieConsent]);
// Do not render the banner if loading or consent is already given
if (isLoading || cookieConsent !== null) {
return null;
}
return (
<div className={`cookie-banner ${cookieConsent == null ? 'visible' : 'hidden'}`}>
<div className="cookie-banner-inner">
<div className="cookie-banner-content">
<div className="cookie-banner-text">
<p>This site uses cookies:</p>
</div>
<div className="cookie-banner-buttons">
<button className="decline-button"
onClick={() => setCookieConsent(false)}>
Decline
</button>
<button className="accept-button"
onClick={() => setCookieConsent(true)}>
Accept
</button>
</div>
</div>
</div>
</div>
);
}
Local Storage Helper
Finally, the storage-helper
file contains two functions to get and set values in local storage.
The getLocalStorage
function retrieves a value from local storage and parses it as JSON, while
the setLocalStorage
function stores a value in local storage after serializing it to JSON.
'use client';
// Retrieves a value from local storage and parses it as JSON.
export function getLocalStorage(key: string, defaultValue: any) {
// Get the value from local storage
const stickyValue = localStorage.getItem(key);
// Check if stickyValue is not null or undefined
if (stickyValue !== null && stickyValue !== undefined) {
try {
return JSON.parse(stickyValue);
} catch (error) {
console.error(`Error parsing JSON for key "${key}":`, error);
return defaultValue;
}
} else {
return defaultValue;
}
}
// Stores a value in local storage after serializing it to JSON.
export function setLocalStorage(key: string, value: any) {
localStorage.setItem(key, JSON.stringify(value));
}
If you got this far, feel free to follow me on X for more content like this.
Footnotes
-
If you forget the suspense boundary, you'll see the error: "Missing Suspense boundary with useSearchParams" ↩