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.
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:
| Strategy | Use it for AdSense? | Why |
|---|---|---|
afterInteractive (default) | Yes | Loads promptly, after first-party hydration — same class as analytics/tag managers |
lazyOnload | No | Waits for browser idle; a well-known cause of AdSense not rendering or verifying |
beforeInteractive | No | For 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: 280reserves 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.
Step 5 — Consent Mode v2 for EEA/UK/CH traffic
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.
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
- Add AdSense to Astro The static-first cousin — where the landmine is Partytown, not routing.
- Add AdSense to Nuxt Same client-side-routing fix in Vue — plus a privacy proxy that's on by default.
- Add AdSense to SvelteKit The afterNavigate equivalent of the pathname key, and the built-in-CSP trap.
- Where to put ads (without wrecking your page) The placements and reserved-space rules that protect Core Web Vitals.
- CPC vs CPM vs RPM: what actually pays you The three numbers to read your earnings by, with one worked example.