Implementing a Custom Cookie Banner in Next.js

Implementing a Custom Cookie Banner in Next.js

When setting up my Next.js site, I opted to build a custom cookie banner instead of using prebuilt solutions. This approach allowed me to maintain a consistent design and add personal touches. The process took time as I had to implement features such as opt-in functionality and conditional rendering based on the current page.

Sorry, somehow the image is not available :(

This article outlines the steps I took to implement and design the cookie banner, which may help you build your own custom solution for a Next.js or React application.

Conditional Rendering

The first step involves declaring a visible state variable, which is initially set to false:

javascript
Copy code
const CookieBanner = ({ debug }) => {
  const [visible, setVisible] = useState(false);

Using useEffect(), the component checks if the cookie consent is undefined or if debug mode is enabled. If so, the cookie banner becomes visible, and scrolling is disabled to ensure the user interacts with the banner first.

javascript
Copy code
useEffect(() => {
    // If cookie is undefined or debug is true, show the banner
    if (Cookie.get("consent") === undefined || debug) {
      document.body.style.overflow = "hidden";
      setVisible(true);
    }
  }, []);

However, when users navigate to pages like /privacy-policy or /site-notice to get some informations about the website. the banner should not obstruct content. To achieve this, the component conditionally renders the banner only if the user has not visited one of these pages:

javascript
Copy code
// Don't render if the banner should not be visible
  if (
    !visible ||
    window.location.href.includes("privacy-policy") ||
    window.location.href.includes("site-notice") ||
    window.location.href.includes("sitemap")
  ) {
    return null;
  }

Additionally, scrolling is re-enabled when users visit these pages. However, if they navigate elsewhere without accepting cookies, the banner reappears, and scrolling is disabled again:

javascript
Copy code
useEffect(() => {
    // Handle page load and visibility
    if (
      window.location.href.includes("privacy-policy") ||
      window.location.href.includes("site-notice")
    ) {
      document.body.style.overflow = "scroll";
    } else if (Cookie.get("consent") === undefined || debug) {
      document.body.style.overflow = "hidden";
    }
  }, []);

Now the user has to finally decied if she or he is fine with using (third-party) cookies. For that reason there will be some explanation in the cookie banner how the cookies are used and the user will find two buttons for 'Accept required and optional cookies' and 'Accept required cookies'.

Sorry, somehow the image is not available :(

Setting Cookies

The banner provides two options: 'Accept required and optional cookies' and 'Accept required cookies'. The first option is highlighted more prominently to encourage users to accept optional cookies.

javascript
Copy code
<Button onClick={() => handleConsent(true)}>Accept required and optional cookies</Button>

Clicking an option triggers the handleConsent() function, which:

  • set the consent cookie with the value true or false depending of the users choice,
  • enable Analytics (optional),
  • enable Adsense (optional),
  • set the visible variable to false and finally
  • allow scrolling again.
javascript
Copy code
const handleConsent = (accepted) => {
    Cookie.set("consent", accepted, { sameSite: "strict", expires: 365 });
    setVisible(false);
    document.body.style.overflow = "scroll";
    if (accepted) {
      // enableGoogleAnalytics();
      // enableGoogleAdsense();
    }
  };

Cookies are created with help of the js-cookie. If you are using Server Component you can also use the cookies function from Nextjs.

The enableGoogleAnalytics() and enableGoogleAdsense() functions are stored separately because they will also be needed in the _app.js file, whichs wrapps the whole application.

The reason behind this is, that the analytics and ad scripts are just injected into the one page where the third-party cookies have been accepted by the user. But as soon as the user navigates to any other page after accepting third-party cookies the injected scripts are not exisiting in this page.

To ensure scripts persist across page changes, the _app.js file rechecks the consent state and reinjects scripts as needed:

javascript
Copy code
const MyApp = ({ Component, pageProps }) => {
  useEffect(() => {
    if (window.location.href.includes(config.domain)) {
      if (Cookie.get("consent") === "true") {
        enableGoogleAnalytics();
        enableGoogleAdsense();
      }
    }
  }, []); // Runs once on mount

  return <Component {...pageProps} />;
};

And that's it. That's how I created a cookie banner with two options which will be rendered conditionally depending on a consent cookie and depending on the current page the user is visiting. The whole CookieBanner component looks like the following:

jsx
Copy code
import styled from "styled-components"
import Link from "next/link"
import media from "styled-media-query"
import Image from "next/legacy/image"
import Logo from "@/components/logo/logo"
import { Button } from "@/styles/templates/button"
import { FaLinkedin } from "@react-icons/all-files/fa/FaLinkedin"
import { FaInstagram } from "@react-icons/all-files/fa/FaInstagram"
import { FaGithub } from "@react-icons/all-files/fa/FaGithub"
import { FaBluesky } from "@react-icons/all-files/fa6/FaBluesky"
import { FaXing } from "@react-icons/all-files/fa/FaXing"
import { SiStrava } from "@react-icons/all-files/si/SiStrava"
//import { enableGoogleAnalytics } from "@/components/google-analytics/google-analytics"
//import { enableGoogleAdsense } from "@/components/google-adsense/google-adsense"
import config from "@/src/data/internal/SiteConfig"
//import { push } from "@socialgouv/matomo-next"
import { useState, useEffect } from 'react';
import Cookie from 'js-cookie'; 

const Background = styled.div`
  position: fixed;
  z-index: 9997;
  right: 0;
  bottom: -200px;
  top: 0;  
  left: 0;
  background-color: rgba(0, 0, 0, 0.5);
`

const CookieContainer = styled.div`
  position: fixed;
  right: 0;
  bottom: 0;
  top: 0;
  left: 0;
  z-index: 9998;
  vertical-align: middle;
  white-space: nowrap;
  max-height: 100%;
  max-width: 100%;
  overflow-x: auto;
  overflow-y: auto;
  text-align: center;
  -webkit-tap-highlight-color: transparent;
  font-size: 14px;
  overflow-y: scroll;
`

const CookieInnerContainer = styled.div`
  width: var(--content-width);
  height: auto;
  max-width: none;
  border-radius: var(--border-radius);
  display: inline-block;
  z-index: 9999;
  background-color: var(--body-bg);
  white-space: normal;
  box-shadow: 0 2px 10px 0 rgb(0 0 0 / 20%);
  position: relative;
  line-height: 1.65;
  border: 1px solid var(--body-bg);
  vertical-align: middle;
  top: 20%;
  ${media.lessThan("medium")`
    width: 90%;
  `}
`

const Wrapper = styled.div`
  max-height: 100%;
  height: auto;
  max-width: none;
  text-align: left;
  border-radius: 16px;
  display: inline-block;
  white-space: normal;
`

const CookieHeader = styled.div`
  padding: var(--space);
  display: flex;
  justify-content: space-between;
`


const CookieContentBlock = styled.div`
  margin-top: var(--space);
  margin-bottom: var(--space-sm)
`

const CookieTextList = styled.ul`
  margin: 0;
  padding: 0;
  padding-inline-start: 1rem;
`

const CookieTextItem = styled.li`
  margin: var(--space-sm) 0;
`

const CookieBannerText = styled.div`
  padding: 0 var(--space);
`

const CookieHeadline = styled.h1`
  font-size: 24px;
  font-weight: 400;
  margin-bottom: var(--space);
`

const Text = styled.div`
  margin-bottom: var(--space-sm);
`

const CookieLink = styled.a`
  border-bottom: 1px solid var(--text-color);
  &:hover {
    border-bottom: none;
  }
  cursor: pointer;
  margin-right: var(--space-sm);
`

const TextLink = styled.a`
  border-bottom: 1px solid var(--text-color);
  &:hover {
    text-decoration: none;
    border-bottom: none;
  }
`

const List = styled.ol`
  list-style: none;
  padding-inline-start: 0;
  display: flex;
`

const SocialItem = styled.li`
  margin: var(--space-sm) var(--space-sm) var(--space-sm) 0;
  transition: 0.2s;
  background-color: var(--content-bg);
  padding: 8px 10px 4px 10px;
  &:hover {
    color: var(--secondary-color);
    cursor: pointer;
  }
`

const ButtonContainer = styled.div`
  margin: var(--space);
  display: flex;
  justify-content: space-between;
  ${media.lessThan("medium")`
    flex-direction: column;
    gap: var(--space-sm);
  `}
`


const CookieBanner = ({ debug }) => {
  const [visible, setVisible] = useState(false);
  
  useEffect(() => {
    const consent = Cookie.get("consent");
    if (!consent || debug) {
      document.body.style.overflow = "hidden";
      setVisible(true);
    } else {
      document.body.style.overflow = "scroll";
    }
  }, [debug]);

  const handleConsent = (accepted) => {
    Cookie.set("consent", accepted, { sameSite: "strict", expires: 365 });
    setVisible(false);
    document.body.style.overflow = "scroll";
  };

  if (!visible || ["privacy-policy", "site-notice", "sitemap"].some((page) => window.location.href.includes(page))) {
    return null;
  }

  const socialLinks = [
    { href: config.socials.bluesky, title: "@mmxdcodes on Bluesky", icon: <FaBluesky /> },
    { href: config.socials.github, title: "mxdietrich on GitHub", icon: <FaGithub /> },
    { href: config.socials.strava, title: "Max Dietrich on Strava", icon: <SiStrava /> },
    { href: config.socials.xing, title: "Max Dietrich on Xing", icon: <FaXing /> },
    { href: config.socials.linkedin, title: "Max Dietrich on Linkedin", icon: <FaLinkedin /> }
  ];

  return (
    <>
      <Background />
      <CookieContainer>
        <CookieInnerContainer>
          <Wrapper>
            <CookieHeader>
              <Logo />
              <Image
                src="/logos/android/android-launchericon-48-48.png"
                width="48"
                height="48"
                title="Max Dietrich"
                alt="Photo of Max Dietrich"
                className="profile u-photo"
              />
            </CookieHeader>

            <CookieBannerText>
              <CookieHeadline>Hi, welcome on mxd.codes 👋</CookieHeadline>
              <CookieContentBlock>
                <p>You can easily support me by accepting optional (third-party)
                cookies. These cookies will help with the following:</p>
                <CookieTextList>
                  <CookieTextItem>
                  <b>Collect audience interaction data and site statistics</b>
                  </CookieTextItem>
                  <CookieTextItem>
                    <b>Deliver advertisements and measure the effectiveness of
                    advertisements</b>
                  </CookieTextItem>
                  <CookieTextItem>
                  <b>Show personalized content (depending on your settings)</b>
                  </CookieTextItem>
                </CookieTextList>
              </CookieContentBlock>
              <Text>
                         <p>
            If you prefer not to share data but still want to support, visit <TextLink href="/support">mxd.codes/support</TextLink> or connect via socials:
            <List>
              {socialLinks.map(({ href, title, icon }) => (
                <SocialItem key={href} title={title}>
                  <a href={href} title={title}>{icon}</a>
                </SocialItem>
              ))}
            </List>
          </p>
                <p>
                  For more information about cookies and how they are used
                  please have a look at the Privacy Policy.
                </p>
              </Text>

              <Link href="/privacy-policy" legacyBehavior>
                <CookieLink>Privacy Policy</CookieLink>
              </Link>
              <Link href="/site-notice" legacyBehavior>
                <CookieLink>Site Notice</CookieLink>
              </Link>
            </CookieBannerText>

            <ButtonContainer>
            <Button onClick={() => handleConsent(false)} backgroundColor="var(--content-bg)" color="#70757a">
              Accept required cookies
            </Button>
            <Button onClick={() => handleConsent(true)}>Accept required and optional cookies</Button>
          </ButtonContainer>
          </Wrapper>
        </CookieInnerContainer>
      </CookieContainer>
    </>
  );
};

export default CookieBanner;

If you also want to know how the previously mentioned enableGoogleAnalytics and enableGoogleAdsense() functions work keep reading.

Google Analytics in Next.js applications

To enable Google Analytics, three functions are used:

  • addGoogleAnalytics() - Injects the analytics script into the document head.
  • initializeGoogleAnalytics() - Configures and initializes Google Analytics.
  • trackGoogleAnalytics() - Tracks page views when users navigate.
javascript
Copy code
export function enableGoogleAnalytics () {
    addGoogleAnalytics().then((status) => {
      if (status) {
        initializeGoogleAnalytics()
        trackGoogleAnalytics()
      }
    })
}

First of all the Analytics script will be created and appended with the individual GA_TRACKING_ID to the head-element.

javascript
Copy code
export function addGoogleAnalytics () {
    return new Promise((resolve, reject) => {
      const head = document.getElementsByTagName('head')[0]
      const scriptElement = document.createElement(`script`)
      scriptElement.type = `text/javascript`
      scriptElement.async
      scriptElement.defer
      scriptElement.src = `https://www.googletagmanager.com/gtag/js?id=${process.env.NEXT_PUBLIC_GA_TRACKING_ID}`
      scriptElement.onload = () => {
        resolve(true)
      }
      head.appendChild(scriptElement);
    });
}

After the script has been added to the site it needs to be initialized. I am also anonymizing IP adresses there and tracking a page view.

javascript
Copy code
export function initializeGoogleAnalytics () {
    window.dataLayer = window.dataLayer || [];
    window.gtag = function(){window.dataLayer.push(arguments);}
    window.gtag('js', new Date())
    window.gtag('config', process.env.NEXT_PUBLIC_GA_TRACKING_ID, {
      'anonymize_ip': true,
      'allow_google_signals': true
    })
    const pagePath = location ? location.pathname + location.search + location.hash : undefined
    window.gtag(`event`, `page_view`, { page_path: pagePath })
}

To be able to also track a user changing pages we will use "next-router". It will track a page_view event everytime the route change has completed (a different page has been visited).

javascript
Copy code
export function trackGoogleAnalytics () {
    Router.events.on('routeChangeComplete', (url) => {
      window.gtag(`event`, `page_view`, { page_path: url })
    });
}

So by calling the function enableGoogleAnalytics() the Google Analytics Script will be added to the page, Google Analytics will be initalized and also all page changes will be tracked with it.

You also can have a look at https://github.com/dietrichmax/google-analytics-next which shows you how you can integrate Google Analytics in Nextjs.

Google Adsense in Next.js applications

The enableGoogleAdsense() function is similiar to the enableGoogleAnalytics() function. It will also create the default Google Adsense script and place it into the head of your react application.

javascript
Copy code
export function enableGoogleAdsense () {
    const head = document.getElementsByTagName('head')[0]
    const scriptElement = document.createElement(`script`)
    scriptElement.type = `text/javascript`
    scriptElement.async
    scriptElement.src = `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${process.env.NEXT_PUBLIC_ADSENSE_ID}`
    scriptElement.crossOrigin = "anonymous"
    head.appendChild(scriptElement);
}

Afterwards you just need to place ad containers with the according client and slot id.

javascript
Copy code
import styled from 'styled-components';
import { useEffect, useState } from 'react';

export function GoogleAdsenseContainer ( { client, slot }) {

  useEffect(() => {
    (window.adsbygoogle = window.adsbygoogle || []).push({});
  }, []);

  const AdLabel = styled.span`
    font-size: 12px;
  `

  return (
    <div 
      style={{textAlign: 'left',overflow: 'hidden'}}
    >
    <AdLabel>Advertisment</AdLabel>
      <ins
        className="adsbygoogle"
        style={{ display: "block" }}
        data-ad-client={client}
        data-ad-slot={slot}
        data-ad-format="auto"
        data-full-width-responsive="true"
      ></ins>

    </div>
  );     
}

In case I missed some important information which you would add please let me know and if you liked the article feel free to share it.

Table of Contents