Max Rohowsky, Ph.D.

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,
                });
                `,
                }}
            />
        </>
    )
}

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

  1. If you forget the suspense boundary, you'll see the error: "Missing Suspense boundary with useSearchParams"