Skip to content
5 min read

How to fix Cumulative Layout Shift in Next.js

CLS is caused by content that arrives without reserved space: images, fonts, ads and client-only widgets. How to find each shift and reserve space for it.

On this page
  1. Find the shifts before fixing anything
  2. Images: dimensions are not optional
  3. Fonts: swap without a jolt
  4. Client-only content: the guaranteed shift
  5. Everything else that arrives late
  6. Measure the way Google does
  7. The takeaway

Cumulative Layout Shift is the Core Web Vital that most often looks fine on your machine and fails in the field. Your fonts are cached, your images are warm, your connection is fast — so nothing moves. On a cold visit over a mid-range phone connection, everything moves.

The underlying cause is always the same: content arrived and the browser had not reserved space for it. Every fix is some version of reserving that space in advance.

Find the shifts before fixing anything

CLS is a sum. Usually one or two shifts dominate and the rest are noise, so start by identifying them rather than hardening everything.

In Chrome DevTools, record a page load in the Performance panel and look at the Experience track. Each shift appears as a marker; selecting one names the element that moved and how much it contributed.

For real sessions, the Layout Instability API reports the same data from the field:

js
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.hadRecentInput) continue;      // user-initiated shifts don't count
    console.log(entry.value, entry.sources.map((s) => s.node));
  }
}).observe({ type: "layout-shift", buffered: true });

The sources array is the useful part — it points at the nodes that actually moved, which is frequently not the element you suspected.

Images: dimensions are not optional

An image without dimensions occupies zero height until it loads, then suddenly occupies several hundred pixels and pushes everything below it down.

With a static import, next/image reads the intrinsic size at build time and reserves the space for you:

tsx
import Image from "next/image";
import cover from "../public/cover.webp";

<Image src={cover} alt="…" className="w-full" />   // dimensions known, space reserved

With a remote source, nothing is known at build time, so you supply it:

tsx
<Image src={url} alt="…" width={1200} height={630} className="h-auto w-full" />

The width and height are an aspect ratio, not a display size — CSS still controls how large it renders. Their job is to let the browser compute the correct height before a single byte arrives.

For images of unknown ratio, reserve the box yourself and crop into it:

tsx
<div className="relative aspect-[16/9] w-full">
  <Image src={url} alt="…" fill className="object-cover" />
</div>

fill requires a positioned parent with its own dimensions. Without one, you are back to reserving nothing.

Fonts: swap without a jolt

A web font loading after first paint re-flows every line of text it touches. The severity depends on how different the fallback's metrics are: if the fallback is narrower, lines re-wrap and everything below moves.

next/font handles the hard part automatically. It self-hosts the font, preloads it, and generates a size-adjusted fallback whose metrics are matched to the real font, so the swap is close to invisible:

app/layout.tsxts
import { Inter } from "next/font/google";

const inter = Inter({ subsets: ["latin"], display: "swap", variable: "--font-inter" });

Two things to check. Load only the weights and subsets you use — each one is a download competing with first paint. And confirm no @import from a font CDN survives in your CSS, because that reintroduces exactly the round trip next/font removed. I went through that in more detail in how I cut First Contentful Paint.

Client-only content: the guaranteed shift

This is the Next.js-specific one, and it is nearly always self-inflicted.

A component that renders nothing on the server and real content after mount produces a shift every single time:

tsx
// ❌ renders null on the server, content after hydration — a shift on every load
"use client";
export function ThemeToggle() {
  const [mounted, setMounted] = useState(false);
  useEffect(() => setMounted(true), []);
  if (!mounted) return null;
  return <button>…</button>;
}

The usual motivation is avoiding a hydration mismatch — the server cannot know the user's theme, locale, or viewport. The fix is not to render nothing; it is to render something of the same size:

tsx
// ✅ occupies its final size from the first paint
if (!mounted) return <span className="block size-11" aria-hidden />;

Better still, avoid the mismatch entirely. For a theme toggle, render both icons and let CSS pick one, so server and client markup are identical and nothing has to wait for mount:

tsx
<svg className="dark:hidden" />
<svg className="hidden dark:block" />

Same applies to next/dynamic with ssr: false — give the component a loading placeholder sized like the real thing, not null.

Everything else that arrives late

  • Ads and embeds. Reserve the slot at its known size. A collapsed slot that expands when the ad fills is pure CLS, and it is the most common failure on content sites.
  • Banners and notices. Cookie bars and announcement strips inserted at the top of the document push the entire page down. Overlay them, or render them in the initial HTML so they are part of the first layout.
  • Web-font icons. An icon font that swaps in late shifts inline text. Inline SVG does not.
  • @font-face without size-adjust. If you hand-roll font faces rather than using next/font, you own the metric matching yourself.

Measure the way Google does

CLS accumulates across the whole page visit, not just load. A shift triggered by something loading five seconds in still counts — which is why lazy content below the fold matters even though nobody has scrolled to it.

Test on a throttled connection with an empty cache, because warm caches hide nearly every shift on this list. Then check Search Console's Core Web Vitals report for the 75th percentile of real sessions, and treat that as the verdict over any lab score. The broader loop for this — lab to test a change, field to confirm it mattered — is in my Next.js performance guide.

The takeaway

Every entry on this list is the same instruction in a different costume: reserve the space before the content arrives.

Images get dimensions or an aspect-ratio box. Fonts get a metric-matched fallback. Client-only components get a placeholder the size of the real thing. Ads and banners get their slot in the initial layout. Do that consistently and CLS stops being a score you chase and becomes a property the layout has by construction.

Frequently asked

What is a good Cumulative Layout Shift score?

0.1 or less at the 75th percentile of real users. Between 0.1 and 0.25 needs improvement, and above 0.25 is poor. Unlike the loading metrics, CLS accumulates over the whole lifetime of the page visit, so a shift caused by something loading late still counts against you minutes after load.

How do I find what is causing layout shift?

Chrome DevTools' Performance panel marks each shift on the Experience track, and selecting one shows the element that moved and its score contribution. For field data, the Layout Instability API can report shift sources from real sessions. Always identify the specific element first — CLS is a sum of individual shifts, and usually one or two dominate.

Does next/image prevent layout shift?

It does when the dimensions are known. A static import gives Next the intrinsic width and height at build time, and it reserves the space before the image loads. A remote src requires explicit width and height props, or the fill prop with a positioned parent that has its own dimensions. Without either, the space is not reserved and the image will shift content when it arrives.

Why does my page shift when fonts load?

Because the fallback font and the web font have different metrics, so text reflows when the real font arrives. next/font generates a size-adjusted fallback matched to the web font's metrics, which makes the swap close to invisible. Without that adjustment, swap is visibly jarring and shift is unavoidable.

Found this useful?Share on XShare on LinkedIn

Related reading