Skip to content
codebiy
← Back to the writing desk

AdSense, Consent Mode v2 and a CSP without wildcards on static Next.js

The ad library is in every prerendered page and stores nothing before a decision: a defaults file, pauseAdRequests and a policy listing each origin. A live visit found one frame blocked; the slots serve no ads yet.

codebiy.com is prerendered: Next.js 16 with React 19 produces every page once, at build time. Its templates hold two ad slots, after the third entry of the blog index and under each article. Three requirements follow: the ad code has to be in the HTML for every visitor, the ad and analytics code may store nothing before the visitor decides, and the Content Security Policy (CSP) should name exactly who is allowed to run.

How far this is checked: on 9 October 2026 the policy passed everything the ad library requested on the live site after “Accept all”, except one frame, which we have added since. No ad was served in that visit, because the production build has no slot id yet. The consent flow and the library's own requests are checked; a served ad is not.

The ad library loads for every visitor, with consent denied

Before the August 2026 rebuild, an AdSense loader waited behind the consent banner and then loaded a library that had nothing to fill: the repository held no ad slot. The rebuild added real slots, and its first version still kept the loader behind the banner. We changed that the same afternoon, for two reasons.

Behind a banner, the served HTML contains no ad code. Google's help page for a site that is not ready to show ads lists “Your ad code is missing or incomplete” as a cause and asks whether the code is in the site's HTML. For anything that fetches a page and never presses a button, a reviewer or a crawler, ours was not. That a site review sees the page this way is our inference, not a statement from Google.

The second reason is how Consent Mode works: the page sets defaults, and a tag that is already loaded reads them and behaves accordingly (Google's setup guide). A tag that never loads has no consent state for Google to read.

So the loader became a server component rendered in <head>. It emits two <script defer> tags in a fixed order: our own defaults file first, Google's library second. The defaults are Consent Mode v2, which added ad_user_data and ad_personalization to the two older signals, and every signal starts denied:

window.dataLayer = window.dataLayer || [];
function gtag() {
  dataLayer.push(arguments);
}
gtag("consent", "default", {
  ad_storage: "denied",
  ad_user_data: "denied",
  ad_personalization: "denied",
  analytics_storage: "denied",
  wait_for_update: 500,
});

Google's guide says the defaults must run before any command that sends data. The usual way to write this is an inline script next to an async script tag, and in React 19 that order does not hold: React moves a <script async src> into <head> and leaves an inline script where it was rendered. Our first attempt put the library ahead of the defaults. The defaults therefore live in a file under public/, and both tags are defer. Deferred scripts execute in document order, so the browser guarantees the order.

Analytics did not move: its script is still rendered only after the visitor allows analytics.

No ad cookie before a decision: pauseAdRequests

Denied defaults were not the end of it. In our test on 8 August 2026, with every signal denied, a browser that had decided nothing still received a test_cookie from doubleclick.net: loading the library was enough. That is a non-essential cookie written before any consent.

The defaults file got two more lines:

window.adsbygoogle = window.adsbygoogle || [];
window.adsbygoogle.pauseAdRequests = 1;

pauseAdRequests holds every ad request until it is set back to 0. We do that in one React effect, together with the consent update:

const value = (ok: boolean) => (ok ? "granted" : "denied");
window.gtag?.("consent", "update", {
  ad_storage: value(consent.ads),
  ad_user_data: value(consent.ads),
  ad_personalization: value(consent.ads),
  analytics_storage: value(consent.analytics),
});

if (window.adsbygoogle && consent.ads)
  window.adsbygoogle.pauseAdRequests = 0;

The effect runs whenever the decision changes. A returning visitor sees no banner: the stored decision is read from localStorage once React has hydrated the page, and the same effect replays it on every page load. That gap is what wait_for_update: 500 is for. It asks Google's tags to wait up to 500 ms for an update before they send data. We have not measured how often hydration finishes inside that window on a slow phone. When it does not, the tags proceed on the denied defaults, and ad requests stay paused either way.

The update goes through gtag(). Pushing a plain array onto dataLayer looks equivalent and is not. We tried both on a test page in desktop Chrome on 9 October 2026: after dataLayer.push(["consent", "update", …]), gtag.js's consent state held no update and the console stayed empty, while the same values through gtag() registered.

Around that decision:

  • A rejection shows no ad and leaves no gap. Unless ads were allowed, the slot component renders nothing, not even its box.
  • A decision expires. It is stored with a timestamp and lasts a year.
  • There is a way back. A “Cookie settings” entry in the footer puts the banner on screen again.

Static pages added one bug of their own. A prerendered page is the same HTML for everyone and cannot know whether this visitor has already chosen. Our first version treated “not read yet” and “not decided” as one state, so the banner painted and then vanished on every refresh, also for visitors who had decided months ago. The fix is a ready flag that turns true once localStorage has been read; until then the banner renders nothing.

The CSP as served: every origin by name, no nonce

next.config.ts sets the policy for every route. This is the header the site sends, wrapped for reading:

default-src 'self';
script-src 'self' 'unsafe-inline' https://pagead2.googlesyndication.com
  https://www.googletagmanager.com https://googleads.g.doubleclick.net
  https://tpc.googlesyndication.com https://adservice.google.com
  https://ep1.adtrafficquality.google https://ep2.adtrafficquality.google
  https://challenges.cloudflare.com;
style-src 'self' 'unsafe-inline';
img-src 'self' data: https://pagead2.googlesyndication.com
  https://www.google-analytics.com https://googleads.g.doubleclick.net
  https://tpc.googlesyndication.com https://ep1.adtrafficquality.google
  https://ep2.adtrafficquality.google;
font-src 'self' data:;
connect-src 'self' https://www.google-analytics.com
  https://pagead2.googlesyndication.com https://googleads.g.doubleclick.net
  https://ep1.adtrafficquality.google https://ep2.adtrafficquality.google;
frame-src https://googleads.g.doubleclick.net
  https://tpc.googlesyndication.com https://ep1.adtrafficquality.google
  https://ep2.adtrafficquality.google https://www.google.com
  https://challenges.cloudflare.com;
object-src 'none'; base-uri 'self'; form-action 'none';
frame-ancestors 'none'; upgrade-insecure-requests

Google's ad and analytics origins are listed by full host name, with no *.google.com and no bare https:. The only other third party in the list is Cloudflare Turnstile, for the check on the contact form.

Illustration: five keys, each with a differently shaped bow, hang on a five-hook key rack, and below it a red switch has its lever down, set to off. Every origin is listed by name, and every consent signal starts denied.

The consent gate had been hiding a violation. The two adtrafficquality.google origins were not in the first policy. One serves a script named Sodar, and the host name says what the pair is for: Ad Traffic Quality is Google's name for its work against invalid traffic. They only showed up once the library loaded for everyone. Behind the banner, a session that had not accepted never ran the library, so nothing was blocked in it, and the policy was still wrong for every visitor who did accept.

There is no nonce, and script-src carries 'unsafe-inline'. This is the weak point of the policy. A nonce has to be different on every response, and Next.js can add one only to pages it renders per request. We kept the static pages and accepted a host allowlist. That is not a strict CSP: an injected inline script would still run. What the policy limits is where scripts load from, where data can be sent, what can be framed and whether a form can post anywhere. Hashes are the usual answer for static pages. Next.js marks its support for them, Subresource Integrity, experimental, and we have not tried it.

The ad slot: reserved height, id from the environment

It reserves its height. The wrapper has a minimum height, 280 pixels by default, so an ad that arrives late does not push the text below it. The wrapper itself is rendered only after an allowed decision has been read, so on a returning visitor's page whatever sits below it moves down at that moment. We have not measured that layout shift.

It takes its id from the environment. A slot id is not a secret: it is printed in every page that shows the ad. We keep it out of the code for another reason. With the id unset the component renders nothing at all, while a made-up id would look configured and serve nothing. These are NEXT_PUBLIC_ values, so they must be present when the image is built, as the article on our Docker build tells.

What the checks and a live visit show, and what they do not

A Playwright script runs these checks against a local build:

  • Policy. It loads the home page in both languages without touching the banner and fails if our own CSP blocks a request.
  • Consent. On the English blog index, a consent default must exist before any decision, with every signal denied. After “Accept all”, an update must have been sent with ad_personalization granted, and the stored decision must carry a timestamp.
  • Cookies. It opens the blog index in a fresh browser context, waits two and a half seconds, and fails if the browser holds a cookie from any other domain.
  • Layout shift. It fails above 0.01 cumulative layout shift on the home page.

These prove the state before a decision, and that accepting sends the update. They never render a filled ad, because the slot ids are unset locally. The policy check never runs after “Accept all”, and the layout check runs on a page without a slot. Our article on checking front-end work written by AI agents is about that kind of gap.

On 9 October 2026 we ran one scripted visit against the live site: desktop Chrome, an English article page, “Accept all”. Before the decision the browser held no cookie. Until that day another visitor could get one before deciding, and it was the site's own: when a page's language differed from the browser's, the next-intl middleware set NEXT_LOCALE, a session cookie, on the first response. Neither our cookie check nor this visit could see it: both ran an English browser on an English page, and the check looks only at cookies from other domains. We turned the cookie off (localeCookie: false): every link on the site carries its language, so it only mattered at an address without one.

After the decision, the ad library requested pagead2.googlesyndication.com, googleads.g.doubleclick.net and the two adtrafficquality.google hosts, and analytics requested www.googletagmanager.com and www.google-analytics.com. All of that passed the policy, except one request of the ad library: a frame to https://www.google.com/recaptcha/api2/aframe, which frame-src blocked. That origin is in the header above. After the decision the browser held three cookies: _ga and _ga_<id> from analytics, and test_cookie on doubleclick.net.

The policy also blocks a script we do not add ourselves: Cloudflare's analytics beacon, from static.cloudflareinsights.com, on every live page we loaded, before and after the decision. The local check cannot see it, because the beacon is not in a local build.

No ad was filled. The library's own placeholder reported unfilled, and the article's slot did not render, because the production build has no slot id configured yet. The policy is checked against the library's requests and not yet against a served ad, so the list may still grow.

None of this is a statement about the law. The checks describe behaviour: what loads, what is stored, and when. Our banner is also not a consent management platform (CMP) certified by Google. Google requires one, integrated with the IAB's Transparency and Consent Framework, for serving personalized ads to visitors in the EEA, the UK and Switzerland. By that page, only traffic from a certified CMP is eligible for personalized ads, and traffic from a non-certified one may be eligible for non-personalized or limited ads.

A checklist for AdSense on a static site

  1. Put the ad library in the HTML for everyone and express the decision through Consent Mode. For personalized ads in the EEA, the UK and Switzerland, it has to come from a Google-certified CMP.
  2. Keep the defaults in a file and load both scripts with defer, defaults first.
  3. Look at the cookies in a fresh browser profile before clicking anything, and once more with a browser language that differs from the page's. If a cookie from the ad network is there, pauseAdRequests is the switch. If it is your own, look at your i18n middleware.
  4. Send consent updates through gtag(), never as a plain array, and replay a stored decision on every page load.
  5. List origins one by one, and run the policy check after “Accept all” on the live site. Expect the list to grow.
  6. Reserve the slot's height, and let an unset slot id render nothing.
KEEP READINGnpm ci rejected a valid lockfile in Docker: npm 10 vs npm 12 ↗Remotion in the Next.js process: five failures, then a Postgres queue ↗