Fixing CLS in Next.js: Find the Element, Not Just the Score
Why PageSpeed's CLS number doesn't tell you what to fix, how to find the exact element and React component behind a layout shift, and the Next.js fixes for the six usual causes.
By Niraj Kumar
Your CLS is 0.24. PageSpeed Insights says "avoid large layout shifts" and lists a
couple of elements. You add width and height to the hero image, redeploy, and
it's still 0.24.
The problem is what the browser actually reports. The Layout Instability API tells you which elements moved. It doesn't tell you what moved them. If an ad slot expands above your article, the shift entry names the article's paragraphs. They're the victims, not the cause, and no fix you apply to them will change anything.
How to find the real culprit
A layout shift is almost always preceded, by a frame or two, by something changing above the content that moved:
- an image finished downloading and got its real height,
- a container grew because a script filled it (ads, embeds, cookie banners),
- React re-rendered a component with a different size after hydration,
- a web font replaced its fallback and text re-wrapped.
So debugging CLS means lining up two timelines: when things moved, and what changed in the DOM just before. You can do it by hand in Chrome DevTools (Performance panel → record a reload → look at the Layout Shift track, then scrub the screenshots around each shift). Or let a tool do the matching. That's what our free CLS Debugger does: it records both timelines in a throttled mobile browser and names the element and, on React sites, the component that caused each shift.
The six causes, and the Next.js fix for each
1. Images without dimensions
The browser reserves 0px for an <img> with no width/height until it downloads.
import Image from "next/image"
<Image src={hero} alt="" width={1200} height={630} sizes="100vw" />
next/image requires dimensions (or fill inside a sized parent), which is why it
rarely causes CLS. A plain <img> in Markdown/CMS content is the usual offender.
2. Ad slots with no reserved space
Ad slots load late and start at zero height. Reserve the tallest creative the slot serves, per breakpoint, and don't collapse it if it doesn't fill:
.ad-slot { min-height: 250px; }
@media (min-width: 768px) { .ad-slot { min-height: 90px; } }
3. Hydration swaps (useMediaQuery, isMounted)
This is the React-specific one, and the one PageSpeed explains worst. A hook like
useMediaQuery can't know the viewport on the server, so the server renders one
branch and the client switches to the other after hydration. On mobile that's a
guaranteed shift:
// ✗ server renders <DesktopNav />, client swaps to <MobileNav />
const isMobile = useMediaQuery("(max-width: 768px)")
return isMobile ? <MobileNav /> : <DesktopNav />
// ✓ both in the HTML, CSS decides, nothing moves
<>
<div className="md:hidden"><MobileNav /></div>
<div className="hidden md:block"><DesktopNav /></div>
</>
The same applies to anything gated on useEffect: an isMounted flag, data fetched
on the client, a "logged in" header that renders after reading localStorage. Either
render the same size placeholder on the server, or move the data fetch into a Server
Component.
4. Content injected above the fold
Cookie banners, promo bars and chat widgets inserted at the top of the page push
everything down. Make them overlays (position: fixed) or reserve their height in
the server HTML.
5. Web-font reflow
When a web font swaps in for its fallback, different metrics re-wrap lines. next/font
fixes this by default: it generates a fallback with size-adjust so the swap doesn't
change line boxes.
6. Animating layout properties
Animating height, top or margin shifts neighbours on every frame. Animate
transform and opacity instead.
Lab vs. field
A lab scan catches what happens on one load. Real visitors scroll, hit unfilled ads and arrive with warm caches, so field data (the 75th percentile Google ranks on) can differ. Once you've fixed what the lab finds, measure real users: the Core Web Vitals module reports each visitor's CLS, LCP and INP along with the selector that caused them, so you can see which shifts real people actually hit.
