adsensethemes

The Stack

How to add AdSense to Next.js without blank slots or CLS

Next.js has two AdSense traps that generic 'add a script' guides walk you straight into: the wrong next/script strategy, and client-side navigation that leaves your ad slots blank. Here's the App Router setup that actually renders.

Living reference · Updated Jul 15, 2026

Next.js can run AdSense beautifully — but two things trip up almost everyone, and both come straight from following a generic “add a third-party script to Next.js” tutorial. The first is the wrong next/script strategy. The second is client-side navigation quietly leaving your ad slots blank. Here’s the App Router version that works.

Step 1 — ads.txt in public/

AdSense won’t serve at full fill until it can confirm you own the inventory. Anything in Next.js’s public/ folder is served from the domain root, so create public/ads.txt:

google.com, pub-0000000000000000, DIRECT, f08c47fec0942fa0

After deploy, confirm it resolves at https://yoursite.com/ads.txt — and that both apex and www resolve (or 301 one to the other), since AdSense checks the root of your registered domain. There’s no app/ads.txt route convention for this; public/ads.txt is the way.

Step 2 — Load the script with the right strategy

Add the loader once in app/layout.tsx using the <Script> component — and use strategy="afterInteractive":

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

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        {children}
        <Script
          id="adsbygoogle-init"
          strategy="afterInteractive"
          async
          crossOrigin="anonymous"
          src={`https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${process.env.NEXT_PUBLIC_ADSENSE_CLIENT}`}
        />
      </body>
    </html>
  );
}

The strategy choice is the whole game here:

StrategyUse it for AdSense?Why
afterInteractive (default)YesLoads promptly, after first-party hydration — same class as analytics/tag managers
lazyOnloadNoWaits for browser idle; a well-known cause of AdSense not rendering or verifying
beforeInteractiveNoFor critical scripts like your consent manager — competes with first-party JS, hurts LCP/INP

afterInteractive is the Core-Web-Vitals sweet spot: the loader is already async, so it never blocks render, but it still loads early enough for reliable fill and verification. Using <Script> (not a raw tag) also means Next.js dedupes it across client-side navigations.

If AdSense site-verification specifically fails, a documented fallback is a raw <script async … crossorigin="anonymous"> in a <head> in the root layout. Try <Script> first — verification failure is the only reason to reach for the raw tag.

Pages Router? Put the same <Script strategy="afterInteractive"> in pages/_app.tsx, or a raw <script> inside pages/_document.tsx.

Step 3 — A reserved-space ad unit (kills CLS and the double-push error)

Render <ins class="adsbygoogle"> in a client component, fire the push in useEffect, reserve the slot’s height, and guard the two Next.js-specific failure modes:

// components/AdUnit.tsx
"use client";
import { useEffect, useRef } from "react";
import { usePathname } from "next/navigation";

declare global {
  interface Window { adsbygoogle: unknown[] }
}

export default function AdUnit({ slot }: { slot: string }) {
  const pathname = usePathname();
  const pushed = useRef(false);

  useEffect(() => {
    if (pushed.current) return; // Strict Mode double-invokes effects in dev
    try {
      (window.adsbygoogle = window.adsbygoogle || []).push({});
      pushed.current = true;
    } catch (e) {
      console.error("adsbygoogle error", e);
    }
  }, []);

  return (
    <ins
      key={pathname}                                 // fresh slot per route
      className="adsbygoogle"
      style={{ display: "block", minHeight: 280 }}   // reserve space → no CLS
      data-ad-client={process.env.NEXT_PUBLIC_ADSENSE_CLIENT}
      data-ad-slot={slot}
      data-ad-format="auto"
      data-full-width-responsive="true"
    />
  );
}

Two lines are doing quiet, important work:

  • minHeight: 280 reserves the slot so content doesn’t jump when the ad fills — unreserved slots are the #1 cause of bad CLS. Match the reserve to the unit you expect (see the placement guide’s reserve table).
  • key={pathname} is the fix for the two errors below.

Step 4 — The gotchas unique to Next.js

Blank slots after navigation. With next/link and the App Router, there’s no full page load, so the loader never re-scans for the new page’s <ins> slots — they stay blank. Giving the <ins> a key tied to usePathname() mounts a brand-new, empty slot on each route, which the loader fills cleanly.

“All ins elements already have ads” in dev. next dev runs under React Strict Mode, which mounts → unmounts → remounts and double-fires effects, so push({}) runs twice against an already-filled <ins>. The useRef guard blocks the second push. It’s largely a dev-only annoyance — but the guard is cheap and keeps your console clean.

Hydration mismatches. AdSense injects an iframe and inline styles into the <ins> after mount. Keep all of that client-side: render the unit inside a "use client" component and only ever call push() in useEffect — never during render or SSR.

Serving ads to the EEA, UK, or Switzerland requires a Google-certified CMP (IAB TCF), and the consent signal must load before the ad loader. This is the one script that genuinely belongs on beforeInteractive — Next.js explicitly names cookie consent managers as that strategy’s use case. Keep AdSense on afterInteractive behind it. Skipping this throttles serving to unconsented EEA traffic, so it’s a revenue issue as much as a compliance one.

One note on Auto ads

Auto ads works fine with the next/script loader — once Step 2 is on the page, enable it per-site in the AdSense dashboard, no extra code. The tradeoff is CLS: Auto ads inserts elements dynamically and can shift layout, where manual units with a reserved min-height give you the control. That control is the whole reason a fast stack pays off twice — once in hosting, once in ad revenue. Prefer Astro’s static output for a pure content site? Here’s the same setup on Astro.

Free guide

Get the playbook

Drop your email and we'll send the AI-era publisher playbook — all nine chapters — straight to your inbox.

No spam. Unsubscribe anytime.

FAQ

Which next/script strategy should I use for AdSense?
Use strategy="afterInteractive" — the default. AdSense is a third-party tag in the same class as analytics and tag managers, which is exactly what afterInteractive is for: it loads promptly but after first-party hydration, and the loader is already async so it never blocks render. Avoid lazyOnload, which waits for browser idle and is a common cause of AdSense failing to render or verify on Next.js. Reserve beforeInteractive for your consent manager, not the ad loader.
Why are my AdSense slots blank after navigating in Next.js?
Because next/link and the App Router navigate without a full page reload, so the AdSense script never re-scans for the new page's ad slots. Force a fresh, empty <ins> element on each route by giving it a React key tied to usePathname() — that mounts a clean slot the loader can fill, and it also avoids the 'all ins elements already have ads' error.
Where does ads.txt go in a Next.js project?
In the public/ folder as public/ads.txt. Anything in public/ is served from the domain root, so it resolves at yourdomain.com/ads.txt, which is where AdSense checks. There's no app/ads.txt route convention for this in the App Router — public/ads.txt is the canonical approach. Make sure both apex and www resolve to it, or 301 one to the other.

Keep reading