Consentl with Next.js

Put Consentl first in <head> of the App Router root layout, load other tags through a small ConsentlScript component, or use the Pages Router _document.

Loader first in <head>
Yes

For prior blocking and Google Consent Mode defaults, Consentl must run before every other tag, synchronously (no async, defer or type="module"). In Next.js that means a plain <script> tag, not next/script.

<script src="https://api.consentl.com/v1/loader.js?k=SITE_KEY&l=LANG"></script>

Store the site key in an environment variable such as NEXT_PUBLIC_CONSENTL_SITE_KEY. The key is public, so exposing it to the browser is fine. Consent Mode needs the Pro plan or higher, automatic blocking of known trackers the Business plan.

App Router (app/layout.tsx)

Tested with Next.js 16.4 and React 19.

What React 19 and Next do to your tags

  • React treats every <script async src> as a resource and moves it to the top of <head>, above a plain synchronous script, even when you wrote it after Consentl. Next's own scripts come first as well.
  • next/script with strategy="afterInteractive" (and beforeInteractive) writes a <link rel="preload"> for the tag into the page. The browser downloads the tag while it reads the page, before consent. Consentl stops it from running, but not that request.

Next's streamed page data (self.__next_f) is never held back by Consentl, whatever it mentions.

  1. Consentl as a plain <script src>, first in <head>. React does not move it, it is in the page once, and visitors always get your current banner settings (the loader is cached for 5 minutes).
  2. Every other tag that must wait for consent (Google Analytics, Meta, TikTok…) through the small ConsentlScript component below. It writes the tag as type="text/plain" with its Consentl category: React does not move it, the browser does not download it, and Consentl runs it once after consent, on every plan, also after client-side navigation.
  3. Or manage those tags in Google Tag Manager (see Google Tag Manager).

Google tags in Consent Mode advanced mode (Pro plan and higher) are meant to load before consent; if you use that mode, load them with next/script and afterInteractive. strategy="lazyOnload" writes no preload (Next 16.4) and is held until consent, but only for tags that automatic blocking recognises (Business plan), so prefer ConsentlScript.

// app/consentl-script.tsx — works in Server and Client Components
type Category = "preferences" | "analytics" | "marketing";

/** A tag that waits for consent: inert in the page, run by Consentl once the category is accepted. */
export function ConsentlScript({ category, src, nonce, children }: { category: Category; src?: string; nonce?: string; children?: string }) {
  return src ? (
    // eslint-disable-next-line @next/next/no-sync-scripts -- type="text/plain": never loaded by the browser itself
    <script type="text/plain" data-consentl={category} src={src} nonce={nonce} />
  ) : (
    <script type="text/plain" data-consentl={category} nonce={nonce} dangerouslySetInnerHTML={{ __html: children ?? "" }} />
  );
}
// app/layout.tsx (or app/[locale]/layout.tsx)
import { ConsentlScript } from "./consentl-script";

const SITE_KEY = process.env.NEXT_PUBLIC_CONSENTL_SITE_KEY!;

export default function RootLayout({ children }: { children: React.ReactNode }) {
  const lang = "en"; // e.g. (await params).locale
  const loader = `https://api.consentl.com/v1/loader.js?k=${encodeURIComponent(SITE_KEY)}&l=${encodeURIComponent(lang)}`;
  return (
    <html lang={lang}>
      <head>
        {/* eslint-disable-next-line @next/next/no-sync-scripts -- must block: consent defaults + prior blocking */}
        <script src={loader} />
      </head>
      <body>
        {children}
        <ConsentlScript category="analytics" src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXX" />
        <ConsentlScript category="analytics">
          {`window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments)}gtag('js',new Date());gtag('config','G-XXXXXXX');`}
        </ConsentlScript>
        <footer>
          <a href="#consentl" data-consentl-open="">Cookie settings</a>
        </footer>
      </body>
    </html>
  );
}

With a Content Security Policy, pass your nonce to ConsentlScript (nonce={nonce}); Consentl keeps it when it runs the tag.

Why not print the loader inline?

An earlier version of this page fetched the loader on the server and printed it inline. In the App Router that sends the loader twice on every full page load, because React also copies the contents of a Server Component's <script> into the page data, and statically built pages keep the copy fetched at build time until they are revalidated. On the same test page:

Setup Page HTML Consentl loader
Plain script tag (recommended) 2.2 KB gzipped 9.5 KB gzipped, one request, cached for 5 minutes and shared by all pages
Inline loader 15.2 KB gzipped, on every full page load in the page twice

The plain tag costs one request to api.consentl.com before the page renders. With every other tag in ConsentlScript (or Google Tag Manager), there is nothing in the page the browser could download before Consentl runs.

next/script with strategy="beforeInteractive" is emitted as a preload plus an inline queue entry; Next inserts the real <script> before hydration, after inline and async scripts already in the server-rendered HTML.

Pages Router (pages/_document.tsx)

In the Pages Router, beforeInteractive is rendered with defer after the CSS links. Use a plain script instead:

import { Head, Html, Main, NextScript } from "next/document";

export default function Document() {
  return (
    <Html lang="en">
      <Head>
        {/* eslint-disable-next-line @next/next/no-sync-scripts */}
        <script src={`https://api.consentl.com/v1/loader.js?k=${process.env.NEXT_PUBLIC_CONSENTL_SITE_KEY}&l=en`} />
      </Head>
      <body>
        <Main />
        <NextScript />
      </body>
    </Html>
  );
}

Client-side navigation

Nothing extra is needed: the runtime stays loaded, the choice lives in the first-party consentl cookie, and "Cookie settings" links work on any route. For your own code, check consent with window.Consentl?.allowed("analytics") and the consentl:change event, and start things only once (see Any website).

Check that it works

Use View Source (not the element inspector) on a page: after Next's own /_next/static/chunks/… scripts, Consentl must come before every other script, there is no <link rel="preload"> for a tracking tag, and your tags are type="text/plain". Then open the site in a private window and check the banner and the Network tab as described in Any website: no request to a tracker before you accept.

Limitations

  • async tags written in the layout are moved above Consentl by React; use ConsentlScript instead.
  • next/script with afterInteractive makes the browser download the tag before consent.
  • With beforeInteractive, scripts already in the server-rendered HTML run before Consentl.
  • A language change on the client does not reload the banner; the new language applies on the next full page load.
  • If you send a Content Security Policy, allow https://api.consentl.com (see Any website).

Stuck? Write to [email protected] — we answer in English on business days.