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.

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:

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.

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:

// 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:

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'.

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.

handleConsent(true)}>Accept required and optional cookies

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.

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:

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:

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>
You can easily support me by accepting optional (third-party)
cookies. These cookies will help with the following:
<CookieTextList>
<CookieTextItem>
Collect audience interaction data and site statistics
</CookieTextItem>
<CookieTextItem>
Deliver advertisements and measure the effectiveness of
advertisements
</CookieTextItem>
<CookieTextItem>
Show personalized content (depending on your settings)
</CookieTextItem>
</CookieTextList>
</CookieContentBlock>
<Text>

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}>
{icon}
</SocialItem>
))}



For more information about cookies and how they are used
please have a look at the Privacy Policy.

</Text>


<CookieLink>Privacy Policy</CookieLink>


<CookieLink>Site Notice</CookieLink>

</CookieBannerText>

<ButtonContainer>
handleConsent(false)} backgroundColor="var(--content-bg)" color="#70757a">
Accept required cookies

handleConsent(true)}>Accept required and optional cookies
</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.

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.

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.

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).

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.

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.

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"
>


);
}

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.