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/scriptwithstrategy="afterInteractive"(andbeforeInteractive) 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.
Recommended setup
- 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). - Every other tag that must wait for consent (Google Analytics, Meta, TikTok…) through the small
ConsentlScriptcomponent below. It writes the tag astype="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. - 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.
Not recommended: beforeInteractive
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
asynctags written in the layout are moved above Consentl by React; useConsentlScriptinstead.next/scriptwithafterInteractivemakes 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).
Related guides
Stuck? Write to [email protected] — we answer in English on business days.