Building in Public#building-in-public

Adding AdSense to Next.js Without Breaking Your CSP Headers

By Mohamed Abdi GuledUpdated recently

AdSense loading silently failed under a strict CSP with no visible error — here is the exact policy scoping that fixed it.

Adding AdSense to Next.js Without Breaking Your CSP

ShabelleHub had a Content Security Policy from day one. It was well-configured — blocking inline scripts, restricting frame sources, preventing clickjacking. When we prepared to add Google AdSense, we discovered that every domain AdSense needs to load ads was missing from the CSP.

If we had added the AdSense script without updating the CSP first, ads would have silently failed to render. No error message to the user. No obvious indication of why. Just blank ad slots and zero revenue.

It's tempting, when this happens, to just drop the Content-Security-Policy header entirely rather than fight with it. That removes a real protection against XSS and data injection for the sake of one advertising script. The better approach — and what we actually did — is scoping the policy tightly to the specific Google domains AdSense needs, and nothing else.


What a CSP Does

A Content Security Policy is an HTTP header that tells the browser which sources are allowed to load resources on a page. A strict CSP blocks everything not explicitly listed — scripts, stylesheets, fonts, frames, and network requests alike.

The original ShabelleHub CSP looked like this:

// next.config.js
const csp = [
  "default-src 'self'",
  "script-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://www.google-analytics.com",
  "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
  "font-src 'self' https://fonts.gstatic.com",
  "img-src 'self' data: https:",
  "connect-src 'self' https://www.google-analytics.com",
  "frame-ancestors 'none'",
  "form-action 'self'",
  "base-uri 'self'",
  "object-src 'none'",
].join('; ');

This is a reasonable CSP for a site without ads. It allows Google Analytics and Google Tag Manager, blocks frames from embedding the site, and restricts everything else to same-origin resources.

AdSense requires significantly more.


What AdSense Actually Needs

Google AdSense loads ads through a network of domains. The AdSense script itself comes from pagead2.googlesyndication.com. Ad creatives load from googleads.g.doubleclick.net. Ad service requests go to adservice.google.com. The ad slot iframes load from tpc.googlesyndication.com.

None of these domains were in the original CSP.

When a browser encounters a resource blocked by CSP, it silently refuses to load it and logs a violation to the console. The user sees nothing. The ad slot stays blank. There is no fallback, no error state, no indication that anything went wrong — just missing revenue.


The Fix (Current Version)

The CSP has evolved since the first fix, as we added more Google services and Supabase. This is the current, complete policy in next.config.js:

const csp = [
  "default-src 'self'",

  // Scripts: AdSense, Analytics, GTM, and Google API domains
  "script-src 'self' 'unsafe-inline' " +
    "https://www.googletagmanager.com " +
    "https://www.google-analytics.com " +
    "https://pagead2.googlesyndication.com " +      // AdSense script
    "https://adservice.google.com " +               // Ad service
    "https://www.googleadservices.com " +           // Ad services
    "https://apis.google.com",                      // Google APIs

  "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
  "font-src 'self' https://fonts.gstatic.com",
  "img-src 'self' data: https:",

  // Frames: AdSense and Google account/video embeds render inside iframes from these domains
  "frame-src 'self' " +
    "https://accounts.google.com " +
    "https://googleads.g.doubleclick.net " +        // Ad iframes
    "https://tpc.googlesyndication.com " +          // Ad creatives
    "https://www.youtube-nocookie.com " +
    "https://www.youtube.com",

  // Connect: AdSense targeting requests, plus Supabase (REST + Realtime)
  "connect-src 'self' " +
    "https://*.supabase.co " +
    "wss://*.supabase.co " +
    "https://www.google-analytics.com " +
    "https://pagead2.googlesyndication.com",

  "frame-ancestors 'none'",
  "form-action 'self'",
  "base-uri 'self'",
  "object-src 'none'",
].join('; ');

The directives that needed updating for AdSense specifically:

  • script-src — needs pagead2.googlesyndication.com, adservice.google.com, *and* www.googleadservices.com. AdSense loads different scripts from different subdomains depending on ad type and account state; missing any one of them can mean some ads render and others silently don't.
  • frame-src — this was the most critical missing piece. Most display ad formats render as iframes from googleads.g.doubleclick.net and tpc.googlesyndication.com. Permitting the ad's script domain in script-src alone is not enough — the iframe itself needs explicit permission here.
  • connect-src — allows AdSense to make network requests for ad targeting. The Supabase entries here are unrelated to AdSense but show why this directive keeps growing as a site adds services.

frame-src vs frame-ancestors

These two directives are frequently confused:

frame-src 'none'         → blocks iframes embedded IN this page
frame-ancestors 'none'   → blocks this page from being embedded IN iframes

frame-ancestors 'none' is a clickjacking protection — it prevents other sites from embedding ShabelleHub in an iframe. This stays in place and is unrelated to AdSense.

frame-src controls what iframes this page is allowed to load. AdSense renders ads inside iframes sourced from Google's domains. Without frame-src allowing those domains, the ad iframes are blocked.


A Note on unsafe-inline

The CSP includes 'unsafe-inline' in script-src. This is a real trade-off, not a mistake — it allows inline <script> tags and javascript: URLs, which reduces the XSS protection CSP provides, but AdSense's initialization pattern relies on inline script execution that we don't control the source of.

The reason it stays: Next.js also inlines script tags for its runtime bootstrap, and removing 'unsafe-inline' requires nonces or hashes, which adds real complexity to the build configuration. For a Next.js Pages Router application without a custom server, the pragmatic trade-off is to keep 'unsafe-inline' and rely on other XSS mitigations (input sanitisation, output encoding, HttpOnly cookies).

If you are implementing CSP for a Next.js application and want to remove 'unsafe-inline', the path is:

    • Use Next.js's built-in nonce support (available in the App Router with middleware)
    • Generate a nonce per request
    • Pass it to <Script> components and inline <script> tags
    • Replace 'unsafe-inline' with 'nonce-{value}' in the CSP header

For the Pages Router, this requires a custom server or middleware. It is worth doing for high-security applications; for a content site like ShabelleHub, the complexity is not currently justified.


The Privacy Policy Requirement

Adding AdSense has a legal requirement that is separate from the CSP: the Privacy Policy must disclose that Google AdSense may use cookies to serve personalised ads.

The original ShabelleHub Privacy Policy mentioned cookies for analytics but said nothing about advertising. We added a dedicated Advertising section:

This site may display advertisements served by Google AdSense and other
third-party advertising networks. These networks may use cookies, device
identifiers, and similar technologies to serve ads based on your visits
to this and other websites.

You can opt out of personalised advertising through Google's Ad Settings,
and learn more about how Google uses data at
https://policies.google.com/technologies/partner-sites.

This is not optional. Google's AdSense programme policies require publishers to have a Privacy Policy that discloses the use of cookies for advertising. A site without this disclosure can be rejected during AdSense review or have its account suspended after approval.


Loading the Script: Strategy Matters as Much as the CSP

The CSP only controls *what's allowed to load* — it says nothing about *when*. Loading AdSense's script synchronously in <head> blocks rendering regardless of how correct the CSP is.

// components/AdSenseScript.js
import Script from 'next/script';

export default function AdSenseScript() {
  const clientId = process.env.NEXT_PUBLIC_ADSENSE_CLIENT_ID;
  if (!clientId || !clientId.startsWith('ca-pub-')) return null;

  return (
    <Script
      async
      strategy="afterInteractive"
      src={`https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${clientId}`}
      crossOrigin="anonymous"
    />
  );
}

Key decisions:

strategy="afterInteractive" — loads the AdSense script after the page becomes interactive, not before first paint. This avoids blocking page render and keeps Core Web Vitals scores intact.

Environment variable for the client ID — the publisher ID (ca-pub-xxxxxxxxxxxxxxxx) lives in NEXT_PUBLIC_ADSENSE_CLIENT_ID. It's not a secret — it appears in the source of every page that runs AdSense — but an environment variable keeps it out of the codebase and makes switching accounts easier.

clientId.startsWith('ca-pub-') — a real validation check, not just an existence check. Combined with the if (!clientId) guard, this means the script renders nothing at all during local development or before AdSense approval — no broken placeholder scripts shipped to production, no console errors from an unconfigured or malformed client ID.


Ad Slots

Individual ad slots are rendered as separate components:

// components/AdSlot.js
import { useEffect } from 'react';

export default function AdSlot({ slot, style = {} }) {
  useEffect(() => {
    try {
      (window.adsbygoogle = window.adsbygoogle || []).push({});
    } catch (e) {
      // AdSense not loaded — development or blocked by extension
    }
  }, []);

  const clientId = process.env.NEXT_PUBLIC_ADSENSE_CLIENT_ID;
  if (!clientId) return null;

  return (
    <ins
      className="adsbygoogle"
      style={{ display: 'block', ...style }}
      data-ad-client={clientId}
      data-ad-slot={slot}
      data-ad-format="auto"
      data-full-width-responsive="true"
    />
  );
}

The try/catch around adsbygoogle.push({}) handles two cases: AdSense not yet loaded (race condition on slow connections) and ad blockers that remove the adsbygoogle global entirely. Neither case should throw an uncaught error.


Testing and Debugging the CSP

After updating the CSP, verify it in the browser's developer tools — specifically the Console, not just the Network tab:

    • Open DevTools → Console
    • Load a page with an ad slot
    • Filter for "Content Security Policy" violations

Any blocked resource appears as a CSP violation log entry naming the exact domain and directive that blocked it:

Refused to load the script 'https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js'
because it violates the following Content Security Policy directive: "script-src 'self' 'unsafe-inline'"

If ads aren't showing and there's no obvious error, this console check is the actual troubleshooting step — cross-reference the named directive against the policy above before assuming it's an AdSense account issue rather than a header issue. No such violations appeared after the update, confirming every required AdSense domain was covered.


The Order of Operations

The sequence that matters for AdSense approval:

    • Update CSP to allow AdSense domains ← do this first
    • Add the Advertising disclosure to the Privacy Policy ← required
    • Add the AdSense script with strategy="afterInteractive" ← last
    • Submit for AdSense review

Adding the script before updating the CSP means ads fail silently on your live site during review — exactly the wrong time for them to fail.


Frequently Asked Questions

Why do ads sometimes fail silently with no visible error under a strict CSP?

A Content Security Policy blocks disallowed script and frame sources without breaking the page — it just prevents the ad from rendering. The only trace is a console error naming the blocked directive, which most people never check.

Which CSP directive is most commonly missing for Google AdSense?

frame-src. Most display ad formats render as iframes from domains like googleads.g.doubleclick.net and tpc.googlesyndication.com — permitting the ad's own script domain in script-src isn't enough on its own.

Is it safe to just disable CSP to get AdSense working?

No. It removes real protection against script injection for the sake of one third-party integration. Scoping the policy precisely to the specific Google domains AdSense needs keeps the protection intact.

Does loading strategy affect whether AdSense works, separately from CSP?

Yes. CSP only controls what's allowed to load, not when. Using Next.js's Script component with strategy="afterInteractive" loads AdSense after the page becomes interactive instead of blocking initial render.


*This article is part of the ShabelleHub Building in Public series.*

*Next: Building FAQPage Schema for Google Rich Results*

Frequently Asked Questions

Why do ads sometimes fail silently with no visible error under a strict CSP?+
A Content Security Policy blocks disallowed script and frame sources without breaking the page — it just prevents the ad from rendering. The only trace is a console error naming the blocked directive.
Which CSP directive is most commonly missing for Google AdSense?+
frame-src. Most display ad formats render as iframes from Google ad-serving domains, and permitting the ad script domain in script-src alone is not enough.
Is it safe to just disable CSP to get AdSense working?+
No. It removes real protection against script injection. Scoping the policy precisely to the specific Google domains AdSense needs keeps the protection intact.

📬 Get the latest AI tool reviews

Expert picks and comparisons, weekly. No spam.

← Back to Blog