Manual Ad Units Not Rendering in Static Sites (Astro / Next Export)

Your AdSense `<ins>` block sits empty on Astro / Next.js / Hugo static exports. Why hydration and component mounting break ad rendering.

On Astro, Next.js static export, SvelteKit, or Hugo, you’ve placed the AdSense <ins> block and the adsbygoogle.js script. View source shows everything is there. Console: window.adsbygoogle.loaded returns false. The slot stays empty forever. This is the static-site / SPA AdSense trap. The script loads but the push({}) call that activates the slot either doesn’t fire, fires too early, or fires twice — and AdSense silently rejects all of these.

The fix is making sure push({}) runs exactly once per slot, after the <ins> is in the DOM. Sounds easy; the lifecycle of modern frameworks makes it not.

Common causes

Ordered by hit rate, highest first.

1. Push fires before <ins> is mounted (race condition)

Your component:

useEffect(() => {
  (window.adsbygoogle = window.adsbygoogle || []).push({});
}, []);

If the effect runs in a layout that renders before the <ins> is committed to the DOM (or before hydration), AdSense’s queue picks up the push but there’s no <ins> to match it to.

How to spot it: document.querySelector('ins.adsbygoogle') from console. If it returns an element but data-ad-status is undefined, the push happened too early.

2. Push fires multiple times for the same slot

SPA route change re-runs your useEffect even though the <ins> from the previous render is still there. AdSense sees two pushes for one slot and refuses to fill.

How to spot it: Console shows Failed to load resource or TagError: All ins elements in the DOM with class=adsbygoogle already have ads in them. That’s a double-push.

3. SSG only renders HTML — no client-side push happens

Astro / Next static export with client:load missing on the ad component. The <ins> is in the HTML but no JavaScript runs to push.

How to spot it: window.adsbygoogle is [] (empty array) even after page load — the push() never executed.

4. Component rendered with client:only and timing is wrong

Astro’s client:only defers rendering until the JS runs. The <ins> appears late; if the AdSense script ran earlier and pushed, it found no matching slot.

How to spot it: Timeline shows <ins> appearing 1-2 seconds after page load. AdSense script ran first, found 0 slots.

5. AdSense respects production-only

Local dev (localhost) and Vercel preview URLs are never served real ads. The slot stays blank — but it’s expected, not a bug.

How to spot it: Same code on production domain works; staging/preview doesn’t. Don’t debug this — test on production.

6. Container blocks scripts (CSP, iframe sandbox)

If your page’s CSP doesn’t allow googlesyndication.com or pagead2.googlesyndication.com, the script loads but the ad iframe can’t render.

How to spot it: Console: “Refused to load script from ‘pagead2.googlesyndication.com’ because it violates the following Content Security Policy directive.”

Shortest path to fix

Step 1: Build a single reusable AdSense component

For Astro with React island (client:load):

// AdSlot.jsx
import { useEffect, useRef } from 'react';

export default function AdSlot({ slotId, format = 'auto' }) {
  const insRef = useRef(null);
  useEffect(() => {
    if (!insRef.current || insRef.current.dataset.adPushed === 'true') return;
    try {
      (window.adsbygoogle = window.adsbygoogle || []).push({});
      insRef.current.dataset.adPushed = 'true';
    } 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" />.

For plain Astro (no React), use a <script> inline:

---
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>(adsbygoogle = window.adsbygoogle || []).push({});</script>

Step 2: Guard against double-push

The data-ad-pushed="true" flag in the component above prevents duplicate push on re-render. Critical for SPA route changes.

Step 3: Handle SPA route changes

For Astro with View Transitions or Next.js App Router:

import { useEffect } from 'react';
import { usePathname } from 'next/navigation';

useEffect(() => {
  // On route change, re-push any new <ins> that hasn't been pushed
  document.querySelectorAll('ins.adsbygoogle:not([data-ad-pushed])').forEach(() => {
    (window.adsbygoogle = window.adsbygoogle || []).push({});
  });
}, [pathname]);

Step 4: Test on production, not localhost

AdSense flat-out doesn’t serve ads on localhost, *.vercel.app preview URLs, or any non-approved domain. Deploy to your real domain and test there. This catches half the support tickets people open on AdSense forums.

Step 5: Verify CSP allows AdSense

If you have a CSP header, it needs:

script-src 'self' 'unsafe-inline' 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;

Adjust to your existing CSP rather than adding 'unsafe-inline' if you can.

Step 6: Wait 24-48h for AdSense to start reporting

Even after a correct fix, AdSense’s earnings dashboard takes 24-48h to reflect the new fills.

When this is not on you

AdSense’s behavior for SPAs and static sites is undocumented and partly inferred from forum reports. Test in real production with patience. The static-site community has done a lot of trial-and-error here.

Easy to misdiagnose as

People assume the AdSense slot is “broken” when actually the AdSense client side hasn’t had a chance to push at the right moment. The script tag loads (showing 200 in Network), but the push never fired or fired wrong.

Prevention

  • Build a single reusable <AdSlot> component. All slot markup goes through it.
  • Always guard push({}) with a per-element flag to prevent duplicates.
  • For SPAs, re-scan and re-push on every route change.
  • Test on production, not localhost.
  • Add a tiny CI test: deploy → curl page → confirm <ins class="adsbygoogle"> appears.

FAQ

  • Does AdSense work on static sites at all? Yes, if push() timing is correct. Most major content sites are static.
  • Should I use Auto Ads instead? Auto Ads handles this automatically — simpler for static sites and reduces the surface area for bugs.

Tags: #AdSense #Monetization #Troubleshooting #Static site