cookieowl

Chapter VII of IX

Cookie consent in Next.js, App Router and Pages Router

Next.js renders on the server and hydrates later, so consent has to start before both. Here is the setup that holds trackers from the first byte.

By Updated 8 min read

Why timing matters in Next.js

A Next.js page arrives as server rendered HTML, then the framework loads its JavaScript chunks and hydrates. Third party scripts added with next/script run at different moments depending on their strategy. If Google Analytics is added with the default afterInteractive strategy and your consent banner is a client component, the tracker can win the race: it runs as soon as the page is interactive, while the banner is still waiting for React.

The fix is to give the consent manager the earliest slot Next.js offers. The beforeInteractive strategy puts a script in the document head and runs it before any first party code. cookieowl sets the Consent Mode defaults and starts holding trackers there, and everything else follows.

App Router setup

Scripts with beforeInteractive belong in the root layout. Pass options as data attributes, exactly as on a plain HTML page:

app/layout.tsx
import Script from 'next/script';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        {children}
        <Script
          id="cookieowl"
          src="https://cdn.jsdelivr.net/npm/cookieowl@0.1.0/dist/cookieowl.js"
          strategy="beforeInteractive"
          data-policy-url="/privacy"
          data-consent-mode=""
          data-auto-block=""
        />
      </body>
    </html>
  );
}

Next.js moves beforeInteractive scripts into the head for you, so the placement inside body is fine. If you would rather host the file yourself, copy node_modules/cookieowl/dist/cookieowl.js into public/ and point src at it.

Google Analytics that waits for consent

Keep Google Analytics in a Script of its own, but give it the inert type and a category. cookieowl releases it when analytics is accepted:

app/analytics.tsx
import Script from 'next/script';

export function Analytics() {
  return (
    <>
      <script
        type="text/plain"
        data-cookieowl="analytics"
        src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXX"
      />
      <script
        type="text/plain"
        data-cookieowl="analytics"
        dangerouslySetInnerHTML={{
          __html: "window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments)}gtag('js',new Date());gtag('config','G-XXXXXXX');",
        }}
      />
    </>
  );
}

Plain script elements are used on purpose: next/script would load them itself, and leaving out async keeps React from treating the first one as a resource to preload. Rendered from a server component, they reach the browser inert and cookieowl picks them up. For tags that a library injects at runtime, auto mode catches gtag.js, Tag Manager, the Meta Pixel and the others by URL.

Reading consent in client components

The React helpers work in client components. With the script tag build, the package exports the same instance, so there is nothing to initialize twice:

app/components/MapEmbed.tsx
'use client';
import { ConsentGate, useConsent } from 'cookieowl/react';
import { cookieowl } from 'cookieowl';

export function MapEmbed() {
  const marketing = useConsent('marketing');
  return (
    <section>
      <h2>Visit the shop</h2>
      {!marketing && <p>The map is hosted by Google, which sets cookies.</p>}
      <ConsentGate
        category="marketing"
        fallback={<button onClick={() => cookieowl.showPreferences()}>Show the map</button>}
      >
        <iframe src="https://www.google.com/maps/embed?pb=YOUR_EMBED_CODE" title="Our shop" />
      </ConsentGate>
    </section>
  );
}

Server components cannot use hooks, but they do not need to: consent is a browser decision. When a server side check is useful, read the cookieowl cookie with cookies() from next/headers and parse its JSON.

Before you ship

Run next build and next start, open the site in a private window and keep the Network panel open. No request to Google, Meta or any other vendor should appear before you choose. Accept analytics and check that gtag.js loads once, then withdraw it and confirm that the page reloads and the _ga cookies are gone. Repeat on a page that uses client side navigation, because a tracker added by a route component is the one people forget. Finally, test with JavaScript disabled: the page still renders, nothing is tracked, and the banner simply does not appear.

Pages Router setup

In the Pages Router, beforeInteractive scripts go in the custom document:

pages/_document.tsx
import { Html, Head, Main, NextScript } from 'next/document';
import Script from 'next/script';

export default function Document() {
  return (
    <Html lang="en">
      <Head />
      <body>
        <Main />
        <NextScript />
        <Script src="https://cdn.jsdelivr.net/npm/cookieowl@0.1.0/dist/cookieowl.js" strategy="beforeInteractive" data-consent-mode="" />
      </body>
    </Html>
  );
}

Questions

Why beforeInteractive and not afterInteractive?

beforeInteractive scripts are placed in the document head and run before any Next.js code and before scripts with other strategies. cookieowl has to set Consent Mode defaults and hold trackers before they run, so it must load first.

Can I use @next/third-parties for Google Analytics?

You can, but its GoogleAnalytics component loads gtag.js right away. Turn on Consent Mode and auto mode so gtag.js is held until analytics is accepted, or render the component inside a ConsentGate.

Does cookieowl break static export or ISR?

No. The banner is rendered in the browser and the choice lives in a cookie, so every page can stay static. Read consent on the client with the hook, not in server components.

Can a server component read the consent?

The record is a regular first party cookie named cookieowl holding JSON, so a server component or a route handler can read it with cookies() if you need to, for example to skip rendering a tracking pixel.

Hoot suggests

Nothing here. Hoot looked under every cookie jar.