Next.js encountered the unstable value Math.random() in a Client Component
This Insight is part of the Instant Navigations feature introduced in Next.js 16.3. If you're new to it, start with the Ensuring instant navigations guide for an overview of what instant navigations are and how Next.js validates them, then come back here for the specific fix.
A Client Component called Math.random() inline during render, and the surrounding tree had no <Suspense> boundary. Client Components are server-side rendered on first load, so Next.js can't bake an unpredictable value into the prerendered HTML. The SSR value won't match the value the client computes on hydration, so you need to choose: defer the value behind a <Suspense> boundary so SSR can stream it, or move the call into useEffect (or an event handler) so it only runs on the client.
The Server Component case is handled at Math.random() during prerendering. Other unpredictable client-side APIs (Date.now(), crypto.randomUUID()) have parallel error pages: Date.now() in a Client Component and Crypto APIs in a Client Component.
Ways to fix this
Wrap in or move into Suspense
Choose this fix when the random value is part of the rendered output and a brief fallback during SSR is acceptable. Wrap the consuming Client Component in <Suspense> from its parent. The fallback ships in the prerendered HTML, and Next.js fills in the real component when the browser hydrates.
Patterns
Wrap from a Server Component parent
Place the <Suspense> boundary in the Server Component that renders the Client Component. The fallback prerenders, the inner Client Component runs in the browser, and you only handle the random value once.
import { Suspense } from 'react'
import { Avatar } from './avatar'
export default function Page() {
return (
<Profile>
<Suspense fallback={<div className="avatar-skeleton" />}>
<Avatar />
</Suspense>
</Profile>
)
}'use client'
export function Avatar() {
const color = `#${Math.random().toString(16).slice(2, 8)}`
return <div style={{ background: color }} />
}Learn more: Streaming.
Trade-off
The component shows the fallback during SSR and the first paint. For above-the-fold UI this can be visible. Use loading.js for full-segment fallback or pick a fallback that matches the final layout so it doesn't cause a layout shift when the component hydrates. See minimizing layout shift.
Gotchas
- Any UI rendered as part of the prerender shell must be deterministic, including
<Suspense>fallbacks,loading.js,error.js,not-found.js, andglobal-error.js. CallingMath.random()in any of them raises this same error. Use stable placeholder content. - The inner Client Component still runs during SSR, behind the boundary. If you need to guarantee the random value only runs in the browser, use Move into effect or event handler instead.
- A
<Suspense>boundary only fixes the prerender/hydration mismatch, not client re-renders. If the component usingMath.random()re-renders on the client (a parent state change, a context update), it produces a new value each time. To stabilize the value across re-renders, callMath.random()once in auseStateinitializer oruseRef, or compute it on the server and pass it down as a prop.
Move into effect or event handler
Choose this fix when the random value isn't needed for the first paint. Move the Math.random() call into useEffect (for first-paint-after-mount values) or an event handler (for interaction values). The initial render uses a deterministic placeholder, so SSR and hydration agree.
Patterns
Use useEffect for an initial value after mount
For values that should appear shortly after the page loads. Initialize state to null (or another deterministic stand-in) and assign the random value inside useEffect.
'use client'
import { startTransition, useEffect, useState } from 'react'
export function Avatar() {
const [color, setColor] = useState('#eaeaea')
useEffect(() => {
// Wrap in startTransition so that if any component below suspends
// during this update, React keeps the existing UI visible instead
// of flashing the nearest outer <Suspense> fallback.
startTransition(() => {
setColor(`#${Math.random().toString(16).slice(2, 8)}`)
})
}, [])
return <div style={{ background: color }} />
}Learn more: useEffect.
Compute on user interaction
When the random value is in response to a click ("reshuffle", "new card"), compute it in the event handler. No SSR concern at all.
'use client'
import { useState } from 'react'
export function Shuffle() {
const [seed, setSeed] = useState(0)
return (
<button onClick={() => setSeed(Math.random())}>Shuffle ({seed})</button>
)
}Lazy-initialize a stable ID with useRef
When a component needs a stable ID for the lifetime of its mount (a tracking ID, a correlation key), produce it lazily inside a useRef getter. The ref initializer runs after mount, so SSR sees null and the browser fills in the value. Subsequent renders read the same ref so the ID stays stable.
'use client'
import { useRef } from 'react'
function getOrCreateId(ref) {
if (!ref.current) {
ref.current = Math.random().toString(36).slice(2)
}
return ref.current
}
export function Workflow({ onNext }) {
const idRef = useRef(null)
return (
<button
onClick={() => {
trackEvent(getOrCreateId(idRef), 'forward')
onNext()
}}
>
Next
</button>
)
}Learn more: useRef.
Trade-off
The user sees the placeholder briefly before the real value. For interactions the wait is invisible, but for useEffect-based values there's a flash of the initial state. See Preventing flash before hydration for techniques that eliminate the flash.
Gotchas
- Don't compute the random value inline during render even with
useState((/* ... */) => Math.random()). The lazy initializer still runs during SSR and triggers the error. - Calling
Math.random()inline during render in a server-rendered Client Component also causes a hydration mismatch (the SSR HTML uses one value, the browser uses another). TheuseEffectand event handler patterns above avoid both the error and the mismatch. - If the value needs to be hydration-stable (the SSR HTML and the hydrated render must match exactly), use Wrap in or move into Suspense instead.
- When you call
setStatefrom insideuseEffect, wrap it instartTransition. Cascading state updates during hydration can cause an outer<Suspense>boundary's fallback to briefly flash.startTransitionmarks the update as non-blocking so React keeps the existing UI in place while the new value resolves.
Other options
Suspend with use(io())
When the read genuinely needs to happen per visit and you can't move it to an effect or event, call io() from next/cache before the read with React's use hook. Client Components prerender on the server during SSR, where the read would otherwise be included in the static shell. use(io()) suspends the prerender so the component is excluded from the shell and rendered on every request from the nearest <Suspense> boundary.
'use client'
import { use } from 'react'
import { io } from 'next/cache'
export function RandomBanner() {
use(io())
return <span>Banner #{Math.floor(Math.random() * 100)}</span>
}Wrap the component in <Suspense> so the surrounding shell stays prerendered.
import { Suspense } from 'react'
import { RandomBanner } from './components/random-banner'
export default function Page() {
return (
<Suspense fallback={null}>
<RandomBanner />
</Suspense>
)
}Learn more: io.
Verifying the fix
After applying a fix, reload the route and confirm the page immediately paints meaningful UI, with any <Suspense> fallbacks covering only the regions that stream in. A <Suspense> boundary placed around the whole page body can pass validation with an empty shell, which defeats the point of an instant navigation.
In next dev, the error overlay points at the failing component with file paths and line numbers. When working from a build instead, the default next build output is more abbreviated. Run next build --debug-prerender for full user-frame stack traces and next build --debug-build-paths /dashboard /settings to iterate on specific routes.
Why instant = false doesn't clear this error
This error fires from the prerender, not from instant-navigation validation. Math.random() returns a different value on every render, so the prerender can't bake it into a static shell regardless of the segment's instant config or experimental.instantInsights.validationLevel. Use one of the fixes above.
Related Insights
- Runtime data during prerendering
- Uncached data during prerendering
- URL data in a Client Component outside of Suspense
- Runtime data in
generateMetadata() - Uncached data in
generateMetadata() - Runtime data in
generateViewport() - Uncached data in
generateViewport() Math.random()while prerenderingDate.now()while prerenderingDate.now()in a Client Component- Crypto APIs while prerendering
- Crypto APIs in a Client Component
- Dynamic data during prefetching
- URL data outside of Suspense
- Unrendered segment
Was this helpful?