next/script beforeInteractive breaks hydration in App Router layouts
You add an analytics snippet or a theme script with next/script, set strategy="beforeInteractive" because you want it to run early, and drop it in your root layout exactly as the docs show. The build is clean. Then the browser console lights up red on every page:
Hydration failed because the initial UI does not match what was rendered on the server.
Nothing in your components changed. You did not touch a date, a random value, or window. It looks like a generic hydration bug, so you go hunting through your tree for a mismatched render that is not there. The cause is not in your components at all. It is the script strategy, and specifically the fact that a beforeInteractive script runs while React is still counting on the DOM to match what the server sent.
beforeInteractive executes before React hydrates. If the script mutates <html> or <body> (adding a theme class, an analytics attribute), the live DOM stops matching the server HTML and React throws a hydration error. Fix it one of two ways: move DOM-mutating scripts to afterInteractive or lazyOnload so they run after hydration, or if the script genuinely must run first, add suppressHydrationWarning to the exact element it mutates (usually the <html> tag). Keep beforeInteractive in the root app/layout only.
The three strategies and when each runs
The whole problem is a timing problem, so the strategies are worth pinning down precisely. Each one changes when the script loads relative to React hydration:
| Strategy | When it runs | Relative to hydration |
|---|---|---|
beforeInteractive | Injected into the initial HTML head, runs before any Next.js code | Before hydration |
afterInteractive (default) | After the page's initial HTML, once hydration begins | During / after hydration |
lazyOnload | During browser idle time, after everything else has loaded | Well after hydration |
Hydration is the step where React takes the static HTML the server sent and attaches its component tree to it, assuming the DOM it finds is the DOM it rendered. That assumption is the load-bearing part. Anything that alters the DOM between the server render and hydration breaks it, and beforeInteractive is designed to run in exactly that window.
Why a beforeInteractive script breaks it
Follow the sequence. The server renders your root layout to HTML, including <html> and <body> with whatever classes your JSX set. That HTML is sent to the browser. Before React hydrates, the beforeInteractive script executes. If it does something like this, which is exactly what a no-flash theme script or many analytics loaders do:
// runs before hydration
document.documentElement.classList.add('dark');
document.documentElement.setAttribute('data-theme', 'dark');
then the live <html> now carries class="dark" and a data-theme attribute that the server-rendered HTML did not have. When React hydrates a moment later, it compares the DOM it expected (from the server HTML) with the DOM actually present (mutated by the script) and finds them different. That difference is the hydration error. Your components are innocent; the DOM was edited out from under React.
The fix, in numbered steps
Step 1: Decide whether the script really needs to run before hydration
Most scripts do not. Analytics, chat widgets, tag managers and feature-flag loaders are all perfectly happy running after the page is interactive. The only genuine beforeInteractive cases are things that must execute before the browser paints or before your app's own code runs: a polyfill your bundle depends on, a bot-detection or consent gate, or a no-flash theme setter.
Step 2: For the common case, move it to afterInteractive or lazyOnload
Drop the strategy back to the default (or lazier) and the mismatch disappears, because the script now runs after React has already hydrated the DOM:
// app/layout.tsx
import Script from 'next/script'
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
{children}
<Script src="https://example.com/analytics.js" strategy="afterInteractive" />
</body>
</html>
)
}
Use lazyOnload for anything non-essential (a live-chat bubble, a heatmap tool) so it does not compete with your app for the main thread during startup.
Step 3: If it truly must run first, isolate the mutation and suppress on that element
A no-flash theme script has to run before paint, so beforeInteractive is legitimate. Keep it in the root layout, and put suppressHydrationWarning on the one element it mutates. This is the pattern every serious theme library uses:
// app/layout.tsx
export default function RootLayout({ children }) {
return (
<html lang="en" suppressHydrationWarning>
<body>{children}</body>
</html>
)
}
suppressHydrationWarning tells React to accept that this specific element's attributes may legitimately differ between server and client, and not warn. It works only on the element you set it on, one level deep, which is exactly why it is safe here: you are declaring that the <html> tag's class is expected to be edited by a pre-hydration script. Do not scatter it across your tree. As covered in why suppressHydrationWarning is not cosmetic, it changes what React reconciles, so aim it narrowly.
Step 4: Keep beforeInteractive in the root layout only
If you place a beforeInteractive script in a page or a nested layout, Next.js cannot inject it into the document head early enough, and you get a build-time complaint:
Error: Script with `beforeInteractive` strategy outside of Document.
Learn more: https://nextjs.org/docs/messages/no-before-interactive-script-outside-document
In the App Router the only valid home for it is the root app/layout file. Next then treats a misplaced one as afterInteractive, which quietly changes its timing and can cause a different set of subtle bugs.
Verification
Reproduce a clean production render, because hydration warnings behave differently in dev:
next build && next start
# open the page, watch the console: no "Hydration failed" message
Confirm the intended element carries the mutation and nothing else drifted:
// in the browser console after load
document.documentElement.className // "dark" -> script ran
// React console: no hydration error -> suppressHydrationWarning did its job
If you moved the script to afterInteractive instead, verify it still runs and simply runs later: the network tab shows the request firing after the initial HTML, and the console is quiet. For a deeper look at chasing down mismatches that really do come from your components, see finding the real hydration mismatch.
What people get wrong
The first reflex is to slap suppressHydrationWarning on the <Script> component itself. It does nothing there. The script is not the element with the mismatched attributes; the <html> tag is. Suppression has to live on the element that actually differs.
The second is to disable server rendering for the whole layout, or wrap half the tree in a client-only boundary, to make the warning stop. That trades a console message for a slower first paint and a flash of unstyled or wrong-themed content. You are papering over a five-line timing issue with an architectural change.
The third is assuming beforeInteractive is a performance win and reaching for it by default. It is not faster; it just runs earlier, and earlier is precisely what causes the mismatch. The default afterInteractive is the right choice for almost everything. Reserve beforeInteractive for the rare script that genuinely cannot wait for hydration, and when you use it, expect to mark the element it touches.
When it is still broken
- The error persists even with an inert script. Some Next.js versions have had a reported quirk where a
beforeInteractivescript in the root layout triggers the warning regardless of what it does. If you hit that, marking the<html>tag withsuppressHydrationWarningis the accepted workaround; check the Next.js issue tracker for your version. - A browser extension is mutating the DOM. Password managers and theme extensions add attributes to
<body>before hydration too. Test in a clean profile or incognito to rule this out before blaming your script. - The script sets state your components also render. If the mutation feeds a value a component displays, suppression on
<html>is not enough. Read the theme from the client after mount, or render a neutral value on the server and reconcile on the client. - E2E tests inject scripts. If only your Cypress or Playwright runs show the error, a test-injected third-party script is the culprit. Gate those scripts behind an environment variable so they do not load during tests.
The lesson that saves future you: a hydration error that appears on every page, unchanged by anything you do in your components, is almost never a component bug. Look at what runs before React hydrates. Nine times out of ten it is a script editing the document, and the strategy field is the whole fix.
Frequently asked questions
- Where is beforeInteractive allowed in the App Router?
- Only inside the root app/layout file. Next.js injects a beforeInteractive script into the initial HTML head so it runs before hydration, and it can only do that from the document root. Placing it in a page or nested layout triggers the no-before-interactive-script-outside-document error and Next silently treats it as afterInteractive.
- Why does a beforeInteractive script cause a hydration mismatch?
- It executes before React hydrates, so if the script adds a class or attribute to <html> or <body>, the live DOM the client hydrates against no longer matches the HTML the server sent. React sees the difference and reports a hydration error, even though nothing in your components changed.
- Should I put suppressHydrationWarning on the Script component?
- No, that does nothing for this problem. suppressHydrationWarning only silences differences on the specific element it is set on, one level deep. Put it on the element the script actually mutates, usually the <html> tag, and only for the attribute being changed. It is an escape hatch, not a general fix.
- Is this the same in the Pages Router?
- No. In the Pages Router, beforeInteractive scripts belong in pages/_document.js, which is outside React's hydration boundary, so mutating the document there does not produce the same App Router root-layout mismatch. The problem described here is specific to app/layout in the App Router.