You placed an AdSense <ins class="adsbygoogle"> block and loaded adsbygoogle.js, but the slot stays blank. View source shows the markup is present. In the console, window.adsbygoogle is still an empty array ([]) or window.adsbygoogle.loaded returns false, and data-ad-status never appears on the <ins>. This is the static-site / SPA AdSense trap.
Fastest fix: the (adsbygoogle = window.adsbygoogle || []).push({}) call has to run exactly once per slot, after that <ins> is committed to the DOM. In framework code that means pushing from inside the component’s mount effect (with a guard against re-runs), not from a global script that may fire before — or after — the slot exists. Skip to Shortest path to fix for the component.
One distinction up front, because it splits the whole problem in two:
- The
<ins>has nodata-ad-statusattribute at all -> AdSense never matched apush()to your slot. This is a code/timing bug and is what this guide fixes. - The
<ins>hasdata-ad-status="unfilled"-> AdSense did process the slot but had no ad to serve. That is an account/inventory/policy matter, not a code bug. See Ads not showing after approval.
Which bucket are you in?
Check the console and the <ins> element, then match the row:
| What you see | Most likely cause | Jump to |
|---|---|---|
window.adsbygoogle is [] after full load; no data-ad-status | No client JS ran the push (SSG / missing client:*) | Cause 3 |
<ins> exists, no data-ad-status, push ran early | Push fired before <ins> mounted | Cause 1 |
TagError: ...already have ads in them in console | Double-push (StrictMode or route change) | Cause 2 |
<ins> appears 1-2s after load, still blank | client:only timing race | Cause 4 |
Works on prod domain, blank on localhost / *.vercel.app | Non-approved domain; ads not served | Cause 5 |
Refused to load script... Content Security Policy | CSP blocks googlesyndication.com | Cause 6 |
data-ad-status="unfilled" | No inventory / account issue, NOT code | Not this guide |
Common causes
Ordered by hit rate, highest first.
1. Push fires before <ins> is mounted (race condition)
A bare effect like this:
useEffect(() => {
(window.adsbygoogle = window.adsbygoogle || []).push({});
}, []);
If this runs in a layout or parent that commits before the <ins> is in the DOM (or before hydration), AdSense queues the push but has no <ins> to bind it to. The push is silently dropped.
How to spot it: in the console run document.querySelector('ins.adsbygoogle'). If it returns an element but the element has no data-ad-status attribute, the push happened too early (or never matched).
2. Push fires multiple times for the same slot
React 18+ in development runs effects twice under StrictMode, and SPA route changes re-run your effect while the previous <ins> is still present. AdSense sees a second push for an already-processed slot and throws.
How to spot it: the console shows Failed to load resource followed by TagError: adsbygoogle.push() error: All ins elements in the DOM with class=adsbygoogle already have ads in them. That exact string is a double-push. AdSense marks a processed slot with data-adsbygoogle-status="done"; a second push on the same element is what trips the error.
3. SSG renders HTML only — no client-side push happens
Astro or Next static export with the ad component shipped as static HTML and no client directive (client:load on Astro, or a Client Component in Next App Router). The <ins> is in the HTML, but no JavaScript ever runs the push({}).
How to spot it: after the page fully loads, window.adsbygoogle is still [] (empty array). The push never executed. On Astro, the island has no client:* directive; on Next, the file is missing "use client".
4. Component rendered with client:only and timing is wrong
Astro’s client:only defers rendering until JS runs, so the <ins> appears late. If the global adsbygoogle.js already evaluated and your push ran against an empty page, it found zero slots.
How to spot it: in the Elements timeline the <ins> appears 1-2 seconds after load. Prefer client:load for ad islands so the <ins> is committed early and the push runs against a real element.
5. AdSense only serves on approved production domains
localhost, *.vercel.app / *.netlify.app preview URLs, and any domain not added and approved in your AdSense account are never served live ads. The slot stays blank, and that is expected.
How to spot it: the same code fills on your real domain but not on staging/preview. Don’t debug this as a code bug. If you need to confirm the markup wires up locally, add data-adtest="on" to the <ins> to render Google test ads on localhost (remove it before deploying — test ads in production are a policy violation).
6. Container blocks scripts (CSP, iframe sandbox)
If your Content-Security-Policy does not allow pagead2.googlesyndication.com and the ad iframe origins, the script is blocked or the ad frame can’t render.
How to spot it: the console shows Refused to load the script 'https://pagead2.googlesyndication.com/...' because it violates the following Content Security Policy directive. Sandboxed iframes need allow-scripts allow-same-origin for the ad frame.
Shortest path to fix
Step 1: Build a single reusable AdSense component
Route every slot through one component so the push logic lives in exactly one place.
For Astro with a React island (client:load):
// AdSlot.jsx
import { useEffect, useRef } from 'react';
export default function AdSlot({ slotId, format = 'auto' }) {
const insRef = useRef(null);
useEffect(() => {
const ins = insRef.current;
// Skip if the slot is missing or AdSense already processed it.
if (!ins || ins.getAttribute('data-adsbygoogle-status') === 'done') return;
try {
(window.adsbygoogle = window.adsbygoogle || []).push({});
} catch (e) {
console.warn('AdSense push failed', e);
}
}, []);
return (
<ins
ref={insRef}
className="adsbygoogle"
style={{ display: 'block' }}
data-ad-client={import.meta.env.PUBLIC_ADSENSE_CLIENT}
data-ad-slot={slotId}
data-ad-format={format}
data-full-width-responsive="true"
/>
);
}
Use as <AdSlot client:load slotId="1234567890" />. Reading AdSense’s own data-adsbygoogle-status is more reliable than a custom flag because StrictMode’s double-invoke and route re-renders both see the same attribute the ad script sets.
For plain Astro (no React), put the push in an inline <script> that runs after the element:
---
const { slotId } = Astro.props;
---
<ins class="adsbygoogle"
style="display:block"
data-ad-client={import.meta.env.PUBLIC_ADSENSE_CLIENT}
data-ad-slot={slotId}
data-ad-format="auto"
data-full-width-responsive="true"></ins>
<script is:inline>(adsbygoogle = window.adsbygoogle || []).push({});</script>
Load the library once in your base layout <head>, not per slot:
<script async crossorigin="anonymous"
src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-XXXXXXXXXXXXXXXX"></script>
Step 2: Guard against double-push
The data-adsbygoogle-status === 'done' check in Step 1 is the guard: once AdSense processes a slot it sets that attribute, so a re-run (StrictMode, re-render, route change) early-returns instead of pushing again. This is what keeps the TagError: ...already have ads in them from firing.
Step 3: Handle SPA route changes
For Astro View Transitions or the Next.js App Router, new <ins> elements appear without a full page load, so re-scan after navigation and push only the ones AdSense hasn’t processed:
import { useEffect } from 'react';
import { usePathname } from 'next/navigation';
useEffect(() => {
document
.querySelectorAll('ins.adsbygoogle:not([data-adsbygoogle-status])')
.forEach(() => {
try {
(window.adsbygoogle = window.adsbygoogle || []).push({});
} catch (e) {
console.warn('AdSense re-push failed', e);
}
});
}, [pathname]);
For Astro View Transitions, run the same scan on the astro:page-load event instead of a React effect.
Step 4: Confirm wiring locally with test ads, then test live on production
AdSense does not serve live ads on localhost, preview URLs, or unapproved domains. Two-step check:
- Locally, temporarily add
data-adtest="on"to the<ins>. Google returns labelled test ads, which proves your markup, client ID, and push timing are correct. Remove the attribute before deploying. - Then deploy to your real, approved domain and verify there. Live fills only happen on the approved domain.
Step 5: Verify CSP allows AdSense
If you send a Content-Security-Policy header, it needs the ad origins:
script-src 'self' https://pagead2.googlesyndication.com https://googleads.g.doubleclick.net;
frame-src https://googleads.g.doubleclick.net https://tpc.googlesyndication.com;
img-src 'self' data: https://*.googlesyndication.com https://*.doubleclick.net;
Prefer a nonce on your inline push script over 'unsafe-inline'. Because the domains AdSense uses change over time, Google recommends a nonce-based strict CSP rather than a fixed domain allowlist; see its CSP guidance if frames still fail.
Step 6: Wait for reporting to catch up
Even after a correct fix, the AdSense earnings dashboard lags real fills by roughly 24-48 hours, and brand-new slots can show low fill while they “warm up”. Don’t judge the fix by revenue on day one — judge it by data-ad-status="filled" in the DOM.
How to confirm it’s fixed
On your live, approved domain, open the rendered page and check, in order:
document.querySelectorAll('ins.adsbygoogle').lengthmatches the number of slots you expect.- Each
<ins>carriesdata-adsbygoogle-status="done"(AdSense processed it). - Each
<ins>carriesdata-ad-status="filled"(an ad was returned)."unfilled"means processing worked but no ad was available — that is inventory, not a code bug. - No
TagErrorin the console.
To stop empty boxes from leaving gaps when a unit is unfilled, hide them with the attribute AdSense sets:
ins.adsbygoogle[data-ad-status="unfilled"] { display: none !important; }
Easy to misdiagnose as
Publishers assume the slot is “broken” when the client side simply never pushed at the right moment. The script tag returns 200 in the Network panel, which looks healthy, but a 200 only means the library downloaded — it says nothing about whether push({}) matched your <ins>. Always check data-adsbygoogle-status / data-ad-status on the element, not just the network request.
Prevention
- Route all slot markup through one reusable
<AdSlot>component. - Guard
push({})by checkingdata-adsbygoogle-statusso re-renders, StrictMode, and route changes can’t double-push. - For SPAs, re-scan and re-push unprocessed
<ins>on every route change. - Confirm markup with
data-adtest="on"locally; verify live fills only on the approved production domain. - Add a CI smoke test: deploy ->
curlthe page -> assert<ins class="adsbygoogle"is present in the HTML.
FAQ
- Does AdSense work on static sites at all? Yes. As long as
push({})runs once per slot after the<ins>mounts, static Astro/Hugo/Next-export sites serve ads normally. Most large content sites are statically generated. - My
<ins>showsdata-ad-status="unfilled"— is my code wrong? No.unfilledmeans AdSense processed the slot but had no ad to serve. That is inventory, geography, or account/policy state, not a timing bug. Check ads not showing after approval. - Why do I only get
TagErrorin development? React StrictMode intentionally double-invokes effects in dev, so the second push hits an already-processed<ins>. Thedata-adsbygoogle-statusguard fixes it; it typically does not reproduce in a production build. - Can I just test on localhost? Only with
data-adtest="on", which returns Google’s test ads and proves your wiring. Live ads never serve onlocalhostor preview URLs, and leavingdata-adtest="on"in production violates AdSense policy. - Should I use Auto Ads instead? Auto Ads inject and push for you, removing the manual timing surface, so they are simpler for static sites. The trade-off is less control over placement — see Auto Ads appear in poor places.