Manual AdSense Ad Units Stay Blank on Astro / Next Static Sites

Your AdSense `<ins>` block sits empty on Astro, Next.js export, SvelteKit, or Hugo. Diagnose the push() timing, double-push, and SSG-no-JS traps and fix the slot.

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 no data-ad-status attribute at all -> AdSense never matched a push() to your slot. This is a code/timing bug and is what this guide fixes.
  • The <ins> has data-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 seeMost likely causeJump to
window.adsbygoogle is [] after full load; no data-ad-statusNo client JS ran the push (SSG / missing client:*)Cause 3
<ins> exists, no data-ad-status, push ran earlyPush fired before <ins> mountedCause 1
TagError: ...already have ads in them in consoleDouble-push (StrictMode or route change)Cause 2
<ins> appears 1-2s after load, still blankclient:only timing raceCause 4
Works on prod domain, blank on localhost / *.vercel.appNon-approved domain; ads not servedCause 5
Refused to load script... Content Security PolicyCSP blocks googlesyndication.comCause 6
data-ad-status="unfilled"No inventory / account issue, NOT codeNot 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:

  1. 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.
  2. 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:

  1. document.querySelectorAll('ins.adsbygoogle').length matches the number of slots you expect.
  2. Each <ins> carries data-adsbygoogle-status="done" (AdSense processed it).
  3. Each <ins> carries data-ad-status="filled" (an ad was returned). "unfilled" means processing worked but no ad was available — that is inventory, not a code bug.
  4. No TagError in 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 checking data-adsbygoogle-status so 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 -> curl the 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> shows data-ad-status="unfilled" — is my code wrong? No. unfilled means 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 TagError in development? React StrictMode intentionally double-invokes effects in dev, so the second push hits an already-processed <ins>. The data-adsbygoogle-status guard 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 on localhost or preview URLs, and leaving data-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.

Tags: #AdSense #Monetization #Troubleshooting #Static site