The Stack
How to add AdSense to Gatsby (and the double-push error to avoid)
Gatsby can still run AdSense cleanly — you just have to inject the loader at build time, and defuse the React double-push error that leaves the console screaming 'already have ads.' Here's the current setup, plus an honest word on whether you should still be on Gatsby at all.
Gatsby will still run AdSense perfectly well — the mechanics are just different from a typical “paste a script tag” flow, and there’s one React error that catches everyone. Let’s do it right, and I’ll be straight with you at the end about whether Gatsby is where you want to be in 2026.
Step 1 — Inject the loader from gatsby-ssr.js
Gatsby renders to static HTML at build time, so the loader goes into <head> via onRenderBody → setHeadComponents. Create or edit gatsby-ssr.js in your project root:
const React = require("react")
exports.onRenderBody = ({ setHeadComponents }) => {
setHeadComponents([
<script
key="google-adsense"
async
src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-0000000000000000"
crossOrigin="anonymous"
/>,
])
}
Two easy-to-miss details: every element in setHeadComponents needs a unique key, and in JSX it’s crossOrigin (camelCase), not crossorigin.
Don’t reach for
gatsby-plugin-react-helmethere — react-helmet doesn’t work correctly under React 18 streaming and is being deprecated. The Gatsby Head API replaces it, but that’s scoped to per-page metadata, not a global build-time loader — sogatsby-ssr.jsis the correct tool for this job.
Step 2 — ads.txt in static/
Put the file at static/ads.txt; everything in Gatsby’s static/ folder is copied verbatim to the root of the build:
google.com, pub-0000000000000000, DIRECT, f08c47fec0942fa0
Confirm it resolves at https://yoursite.com/ads.txt after deploy.
Step 3 — A reserved-space ad unit (with the double-push guard)
This is where Gatsby (and React generally) bites. Render <ins class="adsbygoogle">, reserve its height, and guard against pushing the same slot twice:
// src/components/AdUnit.js
import React, { useEffect, useRef } from "react"
export default function AdUnit({ slot, format = "auto", responsive = true }) {
const insRef = useRef(null)
const pushed = useRef(false)
useEffect(() => {
if (pushed.current) return
const el = insRef.current
if (el && el.getAttribute("data-adsbygoogle-status")) return // already filled
try {
;(window.adsbygoogle = window.adsbygoogle || []).push({})
pushed.current = true
} catch (e) {
console.error("adsbygoogle push failed:", e)
}
}, [])
return (
// Reserve space to prevent layout shift (CLS)
<div style={{ display: "block", minHeight: 280, textAlign: "center" }}>
<ins
ref={insRef}
className="adsbygoogle"
style={{ display: "block" }}
data-ad-client="ca-pub-0000000000000000"
data-ad-slot={slot}
data-ad-format={format}
data-full-width-responsive={responsive ? "true" : "false"}
/>
</div>
)
}
The error you’re avoiding: adsbygoogle.push() error: All 'ins' elements in the DOM with class=adsbygoogle already have ads in them. It fires when push({}) runs against an <ins> Google has already filled. Two things cause that in Gatsby — React 18 Strict Mode double-invoking effects in dev, and client-side navigation re-mounting components. The pushed ref blocks the Strict-Mode second call, and checking data-adsbygoogle-status (the attribute Google sets once a slot is filled) blocks re-pushing a live slot. The wrapping <div> with a min-height reserves the space so the ad never shifts content — the #1 cause of bad CLS. Match the reserve to your unit (see the placement guide’s table).
Step 4 — Re-firing ads on navigation
Gatsby uses client-side routing, so moving between pages doesn’t fully reload — a push that only ran on first load won’t fire again. The clean fix is to let the component do it: Gatsby unmounts the old page and mounts the new one on every route change, so the useEffect in AdUnit re-runs automatically per navigation. Each slot pushes itself when it mounts. No global hook needed.
Resist the tempting-looking global approach — a blanket adsbygoogle.push({}) in gatsby-browser.js’s onRouteUpdate is a known foot-gun: it also fires on initial load and will throw the “already have ads” error if any slot on the new page is already filled. Prefer the per-component useEffect.
Step 5 — Consent Mode v2 for EEA/UK/CH traffic
Serving ads to the EEA, UK, or Switzerland requires a Google-certified CMP, and its consent defaults must load before the AdSense loader. Inject the Consent Mode default block as the first element in setHeadComponents, ahead of the loader:
setHeadComponents([
<script key="consent-default" dangerouslySetInnerHTML={{ __html: `
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
});` }} />,
// your certified CMP loader here (Funding Choices / CookieYes / Cookiebot…),
<script key="google-adsense" async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-0000000000000000" crossOrigin="anonymous" />,
])
A hand-rolled cookie banner does not satisfy the requirement — Google checks for a certified CMP’s TCF signal. The gtag block above just sets the default state; the certified CMP is what actually updates it and gates ads.
The honest part: should you still be on Gatsby?
For an existing Gatsby site, everything above is correct and it’ll serve ads fine. But be clear-eyed: Netlify acquired Gatsby in 2023, and since then it’s been in maintenance mode — releases have slowed sharply and much of the plugin ecosystem is unmaintained. It isn’t dead, but it isn’t actively developed either, and it’s generally not recommended for new projects.
If you’re starting fresh and want the same static-fast, ad-friendly qualities, Astro or Hugo are the livelier picks today — same cost-inversion advantage, with momentum behind them. If you share React components with Next, the Next.js setup reuses almost everything you just read.
FAQ
- How do I add the AdSense script site-wide in Gatsby?
- Inject it at build time from gatsby-ssr.js using onRenderBody and setHeadComponents, which places React elements into the <head> of every page's static HTML. Each element needs a unique key, and in JSX you write crossOrigin (camelCase), not crossorigin. Don't use gatsby-plugin-react-helmet for this — react-helmet is effectively deprecated under React 18; the Gatsby Head API is for per-page metadata, so gatsby-ssr.js remains the right tool for a global loader.
- Why does AdSense throw 'All ins elements already have ads' in Gatsby?
- Because React 18 Strict Mode double-invokes effects in dev, and Gatsby's client-side navigation re-mounts components — so a naive useEffect push() fires twice against an already-filled <ins>. Guard it: check the element's data-adsbygoogle-status attribute and use a ref so you never push the same slot twice. Let the ad component's own useEffect handle re-firing on navigation rather than a global push in gatsby-browser.js.
- Is Gatsby still worth using in 2026?
- For an existing site, it's fine — Gatsby still builds and runs, and AdSense works on it. But since Netlify acquired Gatsby in 2023 the framework has been in maintenance mode with slowed releases and an aging plugin ecosystem, so it's generally not recommended for new projects. If you're starting fresh and want a static, fast, ad-friendly stack, Astro or Hugo are livelier choices today.