Why production-only hydration errors feel impossible
"Works in dev, breaks in production" is the nightmare of frontend engineering. In Next.js App Router apps, React hydration mismatches are a frequent cause: the server renders HTML, the client re-renders and finds a different tree, and React throws the vague console error Hydration failed because the server rendered HTML didn't match the client.
The error is often reproducible only with a production build (next build && next start) because the dev server masks streamed SSR differences and patches some timing/stack traces. Below is a shareable, low-risk 6-step checklist that I use to find and fix these issues — with concrete Ant Design (AntD) and chart examples (Recharts), and a Playwright smoke test to prevent regressions.
1) Always reproduce with a production build
Why: Dev’s streaming SSR, fast-refresh, and error overlays change timings and tree shapes. The bug that survives your deploy usually appears only in the production build.
How: run:
next build && next start -p 3000
# or run the same command your CI uses
If the page is green locally with dev but fails with next start, you’re in the right diagnostic mode.
2) Bisect the tree to find the smallest offender
Trim the page to the simplest form. Comment out subtrees, use feature flags, or toggle components off to find the smallest client-rendered component that changes the markup between server and client. In App Router apps, mismatches must come from a Client Component or serialized props crossing a server/client boundary.
Once you find the component, inspect any uses of time, randomness, browser APIs, or third-party UI.
3) Isolate browser-only libraries (charts, maps, etc.)
Chart libraries often compute layout in the browser (Date.now(), measure, or canvas). If a chart produces numeric ticks that differ between server and client, the rendered SVG will mismatch.
Pattern: render a deterministic server placeholder, then mount the real chart client-side using dynamic import with SSR disabled and hydrate values inside useEffect.
Example:
// components/ChartWrapper.jsx
import dynamic from 'next/dynamic'
import { useEffect, useState } from 'react'
const RechartsClient = dynamic(() => import('./RechartsComponent'), { ssr: false })
export default function ChartWrapper({ data }) {
const [ticks, setTicks] = useState(null)
// server-rendered placeholder will be used for SSR
useEffect(() => {
// compute client-only ticks here (Date.now(), measurements, etc.)
setTicks(computeTicks(data))
}, [data])
return (
<div style={{ minHeight: 300 }}>
{ticks ? <RechartsClient data={data} ticks={ticks} /> : <div aria-hidden>Loading chart…</div>}
</div>
)
}
This ensures the server HTML is deterministic and the browser takes over after hydration.
4) Wire Ant Design’s style registry and avoid mixed ESM/CJS
AntD v6 provides @ant-design/nextjs-registry to extract and inject first-screen CSS for the App Router. Wrap your RootLayout so server-injected CSS matches the client injection order and content.
Example RootLayout:
// app/layout.tsx
import { AntdRegistry } from '@ant-design/nextjs-registry'
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<AntdRegistry>{children}</AntdRegistry>
</body>
</html>
)
}
Also: do not mix CommonJS and ESM imports for AntD. Prefer imports from antd/es/... for components and locales. Mixing antd/lib/... or CJS paths can break React context (locale, theme) and produce hydration mismatches.
Additional AntD notes:
- Ensure
dayjs(or date lib) versions are unified so locale applies both in AntD internals and your app. - Remove legacy compatibility patches like
@ant-design/v5-patch-for-react-19with AntD 6 — they can introduce subtle differences.
5) Guard locale, time, and sub-component imports
Common non-deterministic sources:
- Date/time (Date.now(), toLocaleString)
- Math.random(), crypto.randomUUID(), useId misuse
- Intl and NumberFormat differences by server locale
- Importing AntD subcomponents via dot-notation (e.g.
Layout.Header) — import subcomponents directly from their paths if you see "type is invalid" errors
Strategy: render a neutral server placeholder and compute localized values after hydration. Use useEffect to apply locale-sensitive formatting. Use suppressHydrationWarning sparingly — prefer deterministic placeholders or dynamic imports.
6) Add a Playwright smoke test that runs against a production build
A small Playwright check that runs next build && next start (or hits your deployed staging site) and listens for hydration console errors will catch regressions before they reach production.
Example Playwright snippet:
// tests/hydration.spec.js
const HYDRATION_RE = /hydration failed|server rendered html|did not match/i
test('page hydrates in production', async ({ page }) => {
const errors = []
page.on('console', msg => {
if (msg.type() === 'error' && HYDRATION_RE.test(msg.text())) errors.push(msg.text())
})
await page.goto(process.env.NEXTJS_MONITOR_URL, { waitUntil: 'domcontentloaded' })
// basic interaction to ensure event handlers work
await page.getByRole('button', { name: /open menu/i }).click()
expect(errors, errors.join('
')).toEqual([])
})
Run this in CI against your production-like build. Capture console text, traces, and screenshots on failure.
Concrete example: Recharts + AntD interaction
The bug I chased combined two issues: Recharts used a client-time-dependent tick calculation (Date.now) and AntD injected styles inconsistently between server and client. The fixes that worked together:
- Wrap RootLayout with AntdRegistry
- Replace server chart with a static placeholder and compute ticks inside useEffect
- Dynamic-import the chart with
ssr: false - Ensure all AntD imports use
antd/es/...
Result: the mystery "production-only" error turned into a reproducible, fixable path. Playwright then prevented regressions during future refactors.
Preventive checklist (summary)
- Reproduce with
next build && next startbefore debugging. - Bisect to the smallest Client Component that causes the mismatch.
- Dynamic-import browser-only libraries with
{ ssr: false }and render a deterministic server placeholder. - Use AntdRegistry and keep AntD imports ESM (
antd/es/...). - Avoid rendering locale/time-sensitive values on the server — compute them in useEffect.
- Add a Playwright smoke test against a production build to catch regressions.
Final notes
Next.js hydration mismatch bugs are noisy but traceable: keep outputs deterministic on the server, isolate browser-only logic, and stabilize your CSS/locale pipelines (AntD is a common source). With the six steps above and a small Playwright guard, you can move from surprise production errors to predictable and testable fixes.
Have you encountered a production-only Next.js hydration mismatch? What was the root cause and the smallest change that fixed it? Share your story — these patterns scale across teams and save real outages.
Top comments (0)