Adding a Third-Party Script to React 19 and Next.js: Hoisting, Strict Mode and the Double Push
How to load a third-party script like Google AdSense in a React 19 / Next.js App Router site: scoping it to one section, surviving Strict Mode's double effects, and avoiding layout shift.
Most third-party embeds follow the same recipe: a <script> tag in the page's <head>, an element where the widget should appear, and a line of JavaScript that tells the script to do its work. Ads, analytics, chat widgets and booking calendars all look roughly like this.
The recipe was written for pages built from plain HTML. In a React app, all three steps need a little thought. I hit each of them adding Google AdSense to this blog, and the fixes apply to almost any script you might embed.
Step 1: where the script tag goes
AdSense gives you a snippet like this and asks you to put it in the <head> of every page:
<script async
src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-XXXXXXXXXXXXXXXX"
crossorigin="anonymous"></script>I only wanted ads on the blog, not on the rest of a portfolio that exists to win client work. In the App Router, a layout is the natural way to scope something to one section of a site, so the script goes in app/blog/layout.tsx:
// app/(site)/blog/layout.tsx
export default function BlogLayout({ children }) {
return (
<>
<script
async
src={`https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${client}`}
crossOrigin="anonymous"
/>
{children}
</>
);
}That would have been wrong in older versions of React: the tag would have rendered in the middle of the <body>. React 19 handles this for you. An async script with a src can be rendered anywhere in the tree, and React moves it into the document's <head> and makes sure it is only loaded once, however many components render it.
Why not next/script?
Next.js has its own <Script> component, and for most scripts it is a good choice: its strategy prop lets you load something after the page is interactive, or during idle time. For AdSense specifically, it adds a data-nscript attribute to the tag, and AdSense logs a console warning that it does not support that attribute. The ads still work, but a plain <script async> is what AdSense expects, and React 19 now places it correctly.
One caveat about scoping by layout: it controls where the script starts. Once it has loaded, it stays loaded. If a visitor reads an article and then clicks through to another section, the App Router navigates without a full page reload, so the script is still on the page. For AdSense's automatic ad placement that matters, so I also excluded the non-blog pages in the AdSense dashboard.
Step 2: telling the script to fill a slot
Each ad position is an <ins> element plus one line of JavaScript:
<ins class="adsbygoogle" data-ad-client="ca-pub-..." data-ad-slot="..."></ins>
<script>(adsbygoogle = window.adsbygoogle || []).push({});</script>That second line is the command queue pattern, and you will see it in Google Analytics, Facebook's pixel and many others. If the library has not loaded yet, window.adsbygoogle is just an ordinary array and the push waits in it. When the library arrives, it processes everything queued up. So the push is safe to run before the script has loaded.
In React the push belongs in an effect, because it has to run after the <ins> element exists in the DOM:
"use client";
import { useEffect } from "react";
export function AdUnit({ client, slot }) {
useEffect(() => {
(window.adsbygoogle = window.adsbygoogle || []).push({});
}, []);
return (
<ins
className="adsbygoogle"
style={{ display: "block" }}
data-ad-client={client}
data-ad-slot={slot}
data-ad-format="auto"
data-full-width-responsive="true"
/>
);
}That looks right, and in development it throws an error.
Step 3: Strict Mode runs your effect twice
In development, React's Strict Mode deliberately mounts each component, unmounts it, and mounts it again, running your effects twice. It is there to flush out effects that do not clean up after themselves, and the App Router enables it by default.
The second push hits an <ins> that has already been claimed, and AdSense throws:
TagError: adsbygoogle.push() error: All 'ins' elements in the DOM
with class=adsbygoogle already have ads in them.The usual advice is to write a cleanup function that undoes the effect. There is nothing to undo here: you cannot un-push an ad request. The fix is to make the effect run its work only once per component, using a ref:
const pushed = useRef(false);
useEffect(() => {
if (pushed.current) return;
pushed.current = true;
try {
(window.adsbygoogle = window.adsbygoogle || []).push({});
} catch {
// An ad blocker or a failed request should leave an empty box,
// never a broken article.
}
}, []);This works because Strict Mode's remount reuses the same component instance, so the ref keeps its value between the two runs. The guard has no effect in production, where effects run once anyway, so it costs nothing.
The try/catch is not decoration. Ad blockers often stub or break adsbygoogle, and a third-party error inside an effect should not take your page down with it.
Navigating between articles
In the App Router, moving from one blog post to another replaces the page component, so each article gets a fresh AdUnit and a fresh ref. To make that explicit rather than relying on it, I key the component by the article's slug:
<AdUnit key={post.slug} client={client} slot={slot} />A new key always means a new component instance, and so a new <ins> element with its own push.
Reserve the space
Responsive ads arrive after the page has rendered, at a size you do not know in advance. If the slot starts with zero height, everything below it jumps down when the ad loads. That is the layout shift Google measures as Cumulative Layout Shift, one of the Core Web Vitals.
The cheap fix is to give the slot a minimum height up front:
<ins className="adsbygoogle block min-h-[250px]" ... />Label it, too. A small "Advertisement" caption above the slot is good practice for readers, and it means an unfilled slot looks deliberate rather than broken.
The pattern, for any script
Strip out the AdSense specifics and the same four rules cover most third-party embeds in a React 19 / Next.js app:
- Load the script once, at the highest level that needs it. A layout scopes it to a section, and React 19 hoists and dedupes the tag.
- Trigger it from an effect, through the vendor's command queue, so the order of loading does not matter.
- Guard the effect with a ref when the action cannot be undone, so Strict Mode's double run does not double it.
- Reserve space and catch errors, because third-party content arrives late, at a size you don't control, and sometimes not at all.