Fix Next.js 15 Hydration Flicker
Hero section flickering on load in Next.js 15? Here's why Framer Motion triggers hydration mismatches, and the fix that works for good.
Disclosure: This post may contain affiliate links and display advertising. If you purchase through a link on this page, we may earn a commission at no extra cost to you. See our full disclaimer for details.

I'll show you exactly how I use Web Design to find outdated content, get better insights, and update posts that rank.
I was building a premium animated hero section a few weeks back — floating particles, a staggered headline reveal, the whole Framer Motion treatment. In dev it looked flawless every single time I refreshed. The moment it hit production, it fell apart: a blank white flash for a split second, then the whole hero would visibly "pop" or re-render into place, sometimes with the particles jumping to completely different positions than what had just flashed on screen. Hard refresh, incognito, different browser — same thing every time.
If you're searching for a nextjs 15 hydration flicker fix right now, you're almost certainly staring at some version of this exact problem. The good news is it's not random and it's not a Next.js bug — it's a very specific mistake almost everyone building animated hero sections makes, and once you see it, it's a genuinely quick fix. Here's everything I learned fixing it, including the pattern I now use on every animated section I build.
Why Does Your Next.js Site Flash a Blank Screen on Load?
This part trips people up because it feels like it should just be a "loading is slow" problem. It isn't, really — it's a timing gap.
Next.js 15 renders your page's HTML on the server first and sends that down to the browser. The browser paints that HTML almost immediately, which is the whole point of server rendering — the visitor sees content fast. Then, separately, your JavaScript bundle finishes downloading and React "hydrates" that HTML, attaching event listeners and taking over control of the DOM so the page becomes interactive.
For a static page, that gap is invisible. For an animated hero section built with Client Components and a library like Framer Motion, that gap is exactly where the flicker lives. The server-rendered HTML shows one thing (often the animation's resting or "final" state, or nothing at all if the section depends on client-only values), and then the moment JavaScript takes over, the layout, positions, or opacity values can visibly snap to something different. On a fast connection that's a 100–200ms blink you might not consciously register. On a slower connection, a mid-range Android phone, or a heavier animation bundle, it's a very obvious next 15 blank screen on load moment — and it makes an otherwise polished section look broken.
Here's the part most guides get wrong: the standard fix you'll see everywhere is "just wrap it in next/dynamic with ssr: false." For a small isolated widget, sure. For an above-the-fold hero section, that advice actively makes this worse. Disabling SSR for your hero means the server sends down nothing for that section — no markup at all — until the client JS finishes loading and renders it. You've traded a flicker for a guaranteed blank gap, and you've also handed Google an empty hero on first paint, which is bad for both your Largest Contentful Paint score and how the page looks to anything crawling it before JS executes. That's not a fix, it's just moving the problem and making your Core Web Vitals worse in the process.
The Real Cause: Hydration Mismatches Inside the Hero Itself
Underneath the visible flicker, there's almost always a hydration mismatch happening — and Next.js 15 is genuinely more aggressive about surfacing these than 14 was. Instead of quietly patching things up, React will throw the content away and re-render the whole subtree client-side when it doesn't trust that the server and client output matched, which is exactly the "pop" you're seeing.
Why Math.random() in a Client Component Breaks Hydration
This is the single most common root cause I see in animated hero sections, and it's easy to miss because the code looks completely reasonable:
"use client";
export default function Hero() {
// This looks harmless. It isn't.
const particles = Array.from({ length: 20 }, () => ({
top: `${Math.random() * 100}%`,
left: `${Math.random() * 100}%`,
}));
return (
<section>
{particles.map((p, i) => (
<span key={i} style={{ top: p.top, left: p.left }} />
))}
</section>
);
}
Here's the problem: a Client Component still gets server-rendered for the initial HTML — "use client" doesn't mean "server-side rendering is skipped," it means "this component also runs in the browser." So Math.random() runs once on the server to build the HTML that gets sent down, and then runs again on the client during hydration. Since Math.random() produces a different value every single call, the server's particle positions and the client's particle positions are guaranteed to be different. React sees the mismatch, logs a hydration error, and re-renders — which is your flicker. This exact class of bug is what's officially documented in the Next.js hydration error reference, and it applies just as much to Date.now(), window.innerWidth, or anything else that isn't deterministic between server and client.
The Fix: The Server-Seed Pattern
This is the part most tutorials skip entirely, and it's genuinely the fix that made this problem disappear for me — not a workaround, an actual fix. Instead of generating randomness inside the Client Component (where it runs twice and produces two different results), you generate a single random seed on the server, pass that seed down as a normal prop, and use a deterministic seeded random function on the client to turn that seed into your particle positions. Since the seed itself doesn't change between the server render and the client hydration, the output doesn't either — no mismatch, no re-render, no flicker.
Step 1: Generate the Seed on the Server
In your Server Component (your page.tsx, which has no "use client" at the top), Math.random() is completely safe to use, because it only runs once — on the server, before the HTML is ever sent:
// app/page.tsx (Server Component)
import Hero from "@/components/hero";
export default function Page() {
const seed = Math.floor(Math.random() * 1_000_000);
return (
<main>
<Hero seed={seed} />
</main>
);
}
Step 2: Turn the Seed Into Deterministic "Randomness"
Drop a tiny seeded pseudo-random number generator into your project. Mulberry32 is the one I default to — it's a handful of lines, has no dependencies, and produces the same sequence of numbers every time for a given seed:
// lib/seeded-random.ts
export function mulberry32(seed: number) {
return function () {
seed |= 0;
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
Step 3: Use It Inside Your Client Component
// components/hero.tsx
"use client";
import { useMemo } from "react";
import { motion } from "motion/react";
import { mulberry32 } from "@/lib/seeded-random";
export default function Hero({ seed }: { seed: number }) {
const particles = useMemo(() => {
const random = mulberry32(seed);
return Array.from({ length: 20 }, (_, i) => ({
id: i,
top: `${random() * 100}%`,
left: `${random() * 100}%`,
delay: random() * 2,
}));
}, [seed]);
return (
<section className="relative h-[80vh] overflow-hidden">
{particles.map((p) => (
<motion.span
key={p.id}
className="absolute h-1 w-1 rounded-full bg-white/60"
style={{ top: p.top, left: p.left }}
animate={{ y: [0, -20, 0] }}
transition={{ duration: 4, delay: p.delay, repeat: Infinity }}
/>
))}
</section>
);
}
Because seed comes in as a prop, it's identical whether the component is running during the server's HTML generation or during client hydration a moment later. Feed the same seed into mulberry32, and you get the exact same sequence of "random" numbers both times — same particle positions, same everything, zero mismatch. Each new page load still gets a genuinely fresh layout because the server generates a new seed per request; you're just making sure the server and client agree with each other within a single load. This is the nextjs server seed hydration mismatch fix that next/dynamic and useEffect workarounds can't actually give you, because they either delete the server render entirely or introduce their own visible delay.
How to Stop Framer Motion From Flickering on Initial Load
Fixing the random-seed mismatch solves one flavor of flicker. There's a second, separate one that trips people up even after their hydration errors are gone: the entrance animation itself still looks like it "pops" or flashes in.
Quick naming note since this changes the exact code you'll write: Framer Motion was renamed simply to Motion in 2025, the docs now live at motion.dev, and current versions import from motion/react instead of framer-motion. Same library, same API you already know — just a different package name in your package.json and import statements.
Here's why the next.js 15 framer motion flash happens even without a hydration error involved: when you write initial={{ opacity: 0 }} on a motion component, that opacity: 0 gets baked directly into the server-rendered HTML as an inline style. So the very first thing the browser paints is genuinely invisible content. It stays invisible until Motion's JavaScript finishes loading, hydrates, and fires the transition to your animate state. On a fast machine that gap is tiny. On a real-world connection, or a hero section sharing bandwidth with a big JS bundle, that gap is long enough to read as a stutter, or worse, as blank space where your headline should be.
The fix isn't to avoid animating your hero — it's to stop making the very first paint dependent on JavaScript at all. Swap the entrance animation for pure CSS keyframes, which the browser runs the instant it paints the element, completely independent of React, hydration, or how long your JS bundle takes to arrive:
/* globals.css */
@keyframes hero-enter {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.hero-enter {
animation: hero-enter 0.6s ease-out both;
}
@media (prefers-reduced-motion: reduce) {
.hero-enter {
animation: none;
}
}
<h1 className="hero-enter relative z-10 text-5xl font-bold">
Your headline here
</h1>
No initial, no animate, no JS dependency for this element at all — just a plain class on server-rendered markup. There's nothing for React to mismatch on, and nothing waiting on hydration to fire. Reserve Motion itself for the things that genuinely need JavaScript: scroll-triggered reveals further down the page, hover states, drag gestures, or the floating particles from the section above (which are fine to animate with Motion since their positions are now hydration-safe — it's only the very first above-the-fold paint that needs to be JS-independent). The prefers-reduced-motion block is also worth keeping in every entrance animation you ship — it's a small accessibility win, and it keeps the page squarely in policy-safe territory for things like AdSense review.
Preventing Layout Shift (CLS) Alongside the Flicker
Once the flicker's gone, it's worth checking for its quieter cousin: layout shift. The same hero sections that flicker tend to also shift, and the causes overlap:
- Give the hero container an explicit height or
aspect-ratioso it doesn't collapse to zero height before your particles or images mount, then jump open once they do. - Load custom fonts through
next/fontrather than a CSS@import— it self-hosts and sizes fallback fonts to match, which avoids the classic "text reflows once the web font swaps in" shift. - Avoid measuring the DOM on mount (
element.offsetWidth,getBoundingClientRect) to position particles or elements — that pattern almost always introduces a visible jump between the unmeasured and measured states. The server-seed pattern above sidesteps this entirely since positions are known before anything renders.
Quick Checklist: Confirm the Flicker Is Actually Gone
Don't just eyeball it once — a flicker this fast is easy to miss on a good connection and just as easy to assume is fixed when it isn't:
- Open your browser console and check for hydration warnings on page load — if you still see one, you've got another non-deterministic value hiding somewhere in that component tree.
- Throttle to "Slow 4G" in Chrome DevTools and reload a few times. If the entrance animation still looks instant and the layout doesn't jump, you're good.
- View Source (not DevTools Elements) on the deployed page and confirm your hero markup is actually present in the raw HTML — this rules out anything accidentally still wrapped in
ssr: false. - Run the page through PageSpeed Insights and check that CLS stays comfortably under 0.1.
If all four check out, the flicker's genuinely fixed — not just hidden by a fast local connection.
FAQs
Why does my Next.js website flash a blank screen on load?
It's the gap between the server sending down pre-rendered HTML and your client-side JavaScript finishing hydration. If your hero section depends on client-only values (or was wrapped in next/dynamic with ssr: false), the server has nothing meaningful to send for that section, so the browser paints a blank space until the JS bundle loads and renders it.
How do I fix hydration mismatch errors in Next.js 15?
Find any value that can differ between the server render and the client render — most commonly Math.random(), Date.now(), or reading window directly inside a component — and remove the non-determinism. For hero sections that need "random" layouts, generate a single seed on the server, pass it down as a prop, and use a seeded random function (like mulberry32) on the client so both renders produce identical output from that same seed.
How do I stop Framer Motion from flickering on initial load?
Don't use Motion's initial/animate props for the very first paint of above-the-fold content — that bakes opacity: 0 into the server HTML and leaves it invisible until JS hydrates. Replace the entrance animation with plain CSS @keyframes applied through a class name instead, which the browser runs immediately on paint regardless of hydration timing, and save Motion for interactions that happen after the page is already visible.
Is the server-seed pattern only useful for particle effects? No — it applies to anything "random-looking" you want rendered on the server: shuffled testimonial order, randomized gradient angles, staggered animation delays, generated IDs. The pattern is the same every time: generate the randomness once on the server, pass the seed down, and derive everything else from that seed deterministically on the client.
If Framer Motion isn't the only source of flicker on your site, it's worth checking your theme toggle too — I walked through the exact same server/client timing problem as it shows up in dark mode switching in my Next.js and Tailwind dark mode flicker fix.
About Musab Bin Umair
Expert tech writer and AI enthusiast passionate about exploring the intersection of modern productivity tools and digital growth strategies.
View all posts by Musab Bin Umair →You Might Also Like
View allStay Ahead in AI
Get weekly AI tool updates and tech tips delivered to your inbox.


