Magic Tools
Developer ToolsBy CooconAugust 5, 2026202 views4 min read

Next.js Full-Site Hydration Mismatch: The Root Cause is GTM-Loaded AdSense, Not Your Code

1. Problem Description

Next.js (App Router) site console randomly shows hydration warnings:

A tree hydrated but some attributes of the server rendered HTML
didn't match the client properties.

It's "random" because the reproduction pattern is extremely odd:

  • Cold cache (first visit / hard refresh) opening the first page: console clean
  • Any subsequent pages opened: always report errors
  • Errors are unrelated to specific pages—tool pages, article pages, home page can all trigger it
  • Reproducible in both local dev and production

Following the usual approach, checked all suspicious components: no Date.now() or Math.random() directly in rendering, no typeof window conditional rendering, no browser plugin injections (incognito mode still reports). The page code is clean.

2. Environment

Item Details
Framework Next.js 16 (App Router, React 19)
Tagging Google Tag Manager inline snippet (directly output from layout.tsx, for GSC site verification)
Ads AdSense auto-ads, loaded via GTM

3. Troubleshooting Process

Step 1: Get the full error diff. The warnings in the console are collapsed, so you can't see which attributes don't match. Use Playwright headless browser to capture:

page.on("console", async (msg) => {
  // 注意:msg.text() 只有 "%s" 占位符,拿不到实际内容
  for (const arg of msg.args()) {
    console.log(await arg.evaluate(String));
  }
});
await page.goto(url, { waitUntil: "load" }); // dev 模式 networkidle 永不触发

Two pitfalls that can waste time:

  • msg.text() for React's multi-argument console calls only returns the %s placeholder; you must iterate over msg.args() and evaluate each to get the full diff
  • In dev mode, HMR's long connection makes networkidle never satisfied, so use load as the wait condition

Step 2: Read the diff. The full output shows the mismatch occurs in the GTM inline script in <head>—React expects that position to have the GTM snippet directly output from our layout.tsx, but in the actual DOM, that position has a pagead2.googlesyndication.com script tag.

Step 3: Explain the timing difference between cold and hot cache. This is the most interesting part:

  1. The GTM snippet is directly output in the SSR HTML (must be direct for Google site verification)
  2. After GTM starts, it loads AdSense auto-ads, which dynamically inject their own script into <head>
  3. During React hydration, scripts in head are matched by position
  4. Cold cache: AdSense script needs to download first, injection happens after hydration → match is normal, console clean
  5. Hot cache: AdSense script loads instantly, injection happens before hydration → an extra script appears in head not in server-side HTML, exactly colliding with the inline GTM script React expects to match → error

The characteristic of "cold cache first page clean, subsequent pages always report" perfectly corresponds to "whether the script is already in browser cache"—this is not a code bug, but a race condition between third-party script injection and React hydration, whoever is faster wins.

4. Fix: One Attribute, But Apply It Correctly

Add suppressHydrationWarning to the GTM inline script in layout.tsx:

<script
  id="gtm"
  suppressHydrationWarning
  dangerouslySetInnerHTML={{ __html: gtmSnippet }}
/>

This attribute simply tells React "this node's mismatch is expected, don't warn"—the SSR output HTML won't change a single byte, and GSC site verification remains unaffected.

A Seemingly More 'Proper' but Incorrect Solution

Changing GTM to next/script with strategy="afterInteractive" can also suppress the warning, but don't do this: afterInteractive means the GTM snippet no longer appears in the SSR HTML, while Google site ownership verification (HTML tag method) and some crawler detections rely on the snippet directly output in the initial HTML. Sacrificing site verification for a harmless warning is not worth it.

5. Verification

Before and after the fix, use the same Playwright script to scan major site pages:

  • Before fix: 4 out of 5 pages reported hydration warnings (the only clean one was the cold cache first page)
  • After fix: 5/5 all clean, SSR HTML diff zero

6. Is This Issue Worth Fixing?

Hydration mismatch warnings don't affect functionality—React will continue running with client-side as the source. But leaving it unfixed has two practical costs:

  1. It will drown out real warnings. When the entire site commonly reports errors, if your own components ever have hydration issues, no one will notice an extra line.
  2. Troubleshooting costs will be passed on to the future. Every new colleague (or future you) seeing red text in the console will re-investigate, and since this root cause is hidden deep, each round isn't cheap.

7. Troubleshooting Mantras

  • Hydration warnings first check reproduction pattern: if all pages report and it's unrelated to page code → investigate global injections (tagging, ads, browser plugins), don't check components one by one
  • Cold cache clean, hot cache always reports → race condition type issue, suspect "third-party scripts whose loading speed changes"
  • Collapsed React warnings use Playwright msg.args() to evaluate each for the full text, don't guess with ellipses
  • suppressHydrationWarning should only be used on nodes where the root cause is confirmed and the mismatch is expected—it's an exact exemption, not a silencer

Related Articles

Blocking rm -rf in Claude Code: A Deny Rule Stopped 14 Deletes in Two Weeks, 7 Other Spellings Still Get Through, and a Hook That Closes the Gap

To keep Claude Code from deleting things by accident, I added a deny rule, Bash(rm -rf *), to my global settings. Across my sessions from October 7 to 21 it blocked 14 deletes. After each denial the model picked a new directory name 8 times and dropped the delete 6 times. It never tried another spelling. Then I replayed 22 delete commands verbatim against Claude Code 2.1.285 through a stub. rm -fr, rm -r -f, rm -Rf and rm -r deleted the directory in manual, auto and bypass mode alike. /bin/rm, find -delete and bash -c deleted it in manual and bypass mode. In auto mode, rm -fr inside the working directory skips the classifier because it 'would be allowed in acceptEdits mode'. A second, built-in dangerous-rm check fires on targets like $D/*, even in bypass mode, but loses track of a variable assignment when a heredoc comes earlier in the same command. The post ends with a PreToolUse hook that blocks every recursive spelling in all three modes and tells the model what to do instead.

claude-codehooks+5
pitfallsOct 21, 20268 min
444

The Self-Reinforcing Flywheel Is Harder Than It Looks: 40 Days of an LLM Auto-Tuning Loop

We ran an 'LLM proposes, code decides' auto-tuning loop on a daily WeChat newsletter for 40 days: 18 versions, 8 rollbacks. Three of those rollbacks overturned decisions a human had made, and one version whose knobs were identical to its parent still got rolled back. Using our real numbers, telling apart a 20% difference needs about 25,000 reads per version. At 60 reads a day, that is more than 400 days. This postmortem covers five reasons the flywheel never turned, and what actually moved the numbers.

wechatpostmortem+4
pitfallsOct 21, 20268 min
292

Dev Breakfast · 2026-10-21

Today's headline: A single "bonsoir, Elliot" makes the model leak its .env: a backdoor for under $50. Plus 4 more: DeepSeek open-sources its inference engine: V4.1 Flash only, and KV cache still missing; TorchTPU goes open source: PyTorch models can run directly on TPU; and more.

daily-intelOct 21, 20268 min
640

Dev Breakfast · 2026-10-20

Today's headline: Should You Merge AI Code You Don't Understand: Settle the Abstraction Layer Yourself First. Plus 4 more: tmux as a Remote Control: Multi-Session Claude Code and send-keys; headroom Goes Open Source: 95% Token Savings on JSON, Only 20% for Coding Agents; and more.

daily-intelOct 20, 20265 min
573

Published by Magic Tools