Short answer
In a server-rendered app, most layout shift comes from the client disagreeing with the server about how big something is. Fonts that swap to different metrics, components that render one thing on the server and another after hydration, and streamed fallbacks that are smaller than the content replacing them. Make the server HTML reserve the final size of everything, and CLS mostly takes care of itself.
Server rendering should make layout shift rare: the HTML arrives with the content already in place. In practice, React and Next.js applications often score worse than you would expect, because several things change the layout after that first paint. Hydration swaps in client-only UI, web fonts arrive, streamed sections resolve and consent banners mount.
This guide covers those sources specifically, how to find which one is hurting you, and the fixes that hold up in field data.
How CLS is actually scored
From web.dev’s CLS definition, assessed at the 75th percentile of page loads:
| Rating | CLS |
|---|---|
| Good | 0.1 or less |
| Needs improvement | Over 0.1, up to 0.25 |
| Poor | Over 0.25 |
Three details from the definition explain most of the confusion:
- Each shift is scored as impact fraction × distance fraction. A large element moving a small distance and a small element moving a long way can both score badly.
- Shifts are grouped into session windows. A window is a burst of shifts less than one second apart, capped at five seconds. Your CLS is the worst window, not the sum over the whole visit. A single hydration burst can therefore set your score on its own.
- Shifts within 500 ms of a discrete input are excluded. Expanding an accordion on click is fine. Scrolling does not count as input, so content that jumps in while the user scrolls still counts.
Field CLS covers the whole page lifetime, while a Lighthouse run usually covers only the initial load. A big gap between the two usually means a shift that happens later: lazy content, an infinite list or a late banner.
Where server-rendered apps shift
| Source | When it shifts | Fix |
|---|---|---|
| Web font swap | When the font file arrives and replaces the fallback | Metric-matched fallback (next/font or size-adjust), or font-display: optional |
| Client-only branches | Immediately after hydration | Render the final shape on the server, or reserve its space |
| Streamed Suspense fallbacks | When the streamed content resolves | Skeletons with the same dimensions as the real content |
| Images and embeds without dimensions | When the resource loads | width/height attributes or aspect-ratio |
| Injected banners and ads | Whenever the script runs | Overlay with fixed positioning, or a reserved slot |
Fonts: match the fallback, do not just swap it
With font-display: swap, text renders immediately in a fallback font and re-renders when the web font loads. If the two fonts have different widths and line heights, every paragraph reflows. The fix is a fallback whose metrics are adjusted to match.
In Next.js, next/font does this for you. It self-hosts the font files (no runtime requests to Google), defaults display to 'swap', and generates an adjusted fallback through adjustFontFallback, which defaults to true for Google fonts and 'Arial' for local fonts. The most common way to break it is to turn that off, or to load the same font a second time through a plain stylesheet.
// app/fonts.ts — load each font once and import it where needed.
import { Inter } from 'next/font/google';
import localFont from 'next/font/local';
export const inter = Inter({
subsets: ['latin'],
variable: '--font-inter',
// display: 'swap' and adjustFontFallback: true are the defaults
});
export const brand = localFont({
src: './fonts/brand-variable.woff2',
variable: '--font-brand',
adjustFontFallback: 'Arial', // the default for next/font/local
});
Outside Next.js (a WordPress theme, for example), build the adjusted fallback yourself. size-adjust has been Baseline since 2023, and ascent-override, descent-override and line-gap-override tune the vertical metrics. The values below are placeholders. Derive real ones from your font’s metrics with a calculator, then check them visually.
@font-face {
font-family: "Brand";
src: url("/fonts/brand-variable.woff2") format("woff2");
font-display: swap;
}
/* A local font, scaled to occupy the same space as "Brand". */
@font-face {
font-family: "Brand Fallback";
src: local("Arial");
size-adjust: 104%; /* placeholder: calculate for your font */
ascent-override: 92%; /* placeholder */
descent-override: 24%; /* placeholder */
line-gap-override: 0%;
}
body {
font-family: "Brand", "Brand Fallback", sans-serif;
}
If the brand font is not essential on the first view, font-display: optional removes the swap entirely: the browser uses the web font only if it is available almost immediately, typically from cache on later page views. You trade a guaranteed brand font for a guaranteed zero font shift.
Hydration: the server must render the final shape
The most server-specific source of CLS is a component that cannot know something on the server, renders a default, and corrects itself after hydration. React’s documentation recommends this two-pass pattern for content that has to differ on the client, and also warns that it can feel jarring. If the two passes have different heights, it is a layout shift.
'use client';
import { useEffect, useState } from 'react';
// Shifts: the server renders nothing, the client renders a 64px banner.
export function ReturningVisitorBanner() {
const [returning, setReturning] = useState(false);
useEffect(() => {
setReturning(localStorage.getItem('seen') === '1');
}, []);
return returning ? <div className="h-16">Welcome back</div> : null;
}
The fixes, in order of preference:
- Move the decision to the server. If the information can live in a cookie or the request headers, read it in a server component and render the right thing first time. This is also better for caching than you might expect, as long as you know which cache layer you are varying. The four layers are covered in Next.js caching explained.
- Use CSS instead of JavaScript for viewport decisions. Rendering a mobile or desktop component based on
window.matchMediaafter hydration guarantees a shift for one group of users. Render both and hide one with a media query, or use one responsive component. - Reserve the space. If the content must be client-only, give its container a fixed
min-heighton the server so the late content fills a hole rather than pushing the page down. - Take it out of the flow. Banners and toasts can be
position: fixed. Overlays do not move other content, so they do not cause layout shifts.
Hydration mismatches are worth fixing for the same reason. React treats them as bugs, and recovering from them means re-rendering on the client, which is exactly the kind of late change that moves content.
Streaming: skeletons must be the right size
Streaming with Suspense sends the page shell first and fills in slow sections as they resolve. That is good for TTFB and LCP, but every fallback is a promise about how much space the real content will take. A 40px spinner replaced by a 600px product grid shifts everything below it.
// The fallback reserves the same block size as the resolved content.
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews productId={id} />
</Suspense>
// ReviewsSkeleton.tsx
export function ReviewsSkeleton() {
return <section className="reviews" style={{ minHeight: 480 }} aria-busy="true" />;
}
Watch for loading.tsx in the App Router as well: it wraps the route segment in a Suspense boundary, so a small generic loader in a layout-heavy route produces the same problem. Streaming architecture and boundary placement are covered in more depth in Streaming architecture in Next.js.
Images, embeds and media
Always give images width and height attributes. Browsers use them to compute an aspect ratio and reserve space before the file arrives, and CSS (width: 100%; height: auto) still controls the rendered size. next/image requires dimensions or fill; with fill, the parent must have a defined size or the image has no box to fill. Video, iframes and ad slots need the same treatment with aspect-ratio or min-height, and an ad slot should keep its space even when no ad is returned. Image delivery for the hero itself is covered in the companion piece on LCP, priority hints and image delivery.
For animations, use transform. Animating top, height or margin moves other elements; transform: translate() and scale() do not.
Finding the culprit
In the DevTools Performance panel, the live metrics view shows CLS as you load and use the page, and the Layout shifts tab lists each shift. Record a trace and the Layout shift culprits insight names the shifted elements and likely causes, such as unsized images, web fonts or injected content.
In the field, the web-vitals attribution build reports the element responsible for the largest shift in the worst session window:
import { onCLS } from 'web-vitals/attribution';
onCLS(({ value, attribution }) => {
navigator.sendBeacon('/rum', JSON.stringify({
metric: 'CLS',
value,
target: attribution.largestShiftTarget, // CSS selector of the shifted element
time: attribution.largestShiftTime,
loadState: attribution.loadState,
}));
});
For a quick check in the console, a PerformanceObserver on layout-shift entries logs each shift with its source nodes. The Layout Instability API behind it is Chromium-only, so field CLS data comes from Chromium browsers.
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.hadRecentInput) continue;
console.log(entry.value.toFixed(4), entry.sources?.map((s) => s.node));
}
}).observe({ type: 'layout-shift', buffered: true });
CLS checklist for server-rendered apps
- Fonts load once, through
next/fontor an adjusted@font-facefallback;adjustFontFallbackis not disabled. - No component renders a different height before and after hydration; personalisation is decided on the server where possible.
- Viewport-dependent layout is CSS, not
matchMediain render logic. - Every Suspense fallback and
loading.tsxreserves the size of what replaces it. - Every image, video, iframe and ad slot has dimensions or an aspect ratio.
- Banners, toasts and consent prompts are overlays, not in-flow inserts at the top of the page.
- Animations use
transform. - Field CLS is collected with attribution, so a regression points at an element rather than a page.
Questions people actually ask
- Does font-display: swap cause layout shift?
- It can. Swap shows fallback text first and replaces it when the web font loads; if the fonts have different metrics, text reflows and moves content. A metric-matched fallback makes the swap close to invisible, and
font-display: optionalavoids it altogether. - Do layout shifts after the page has loaded count towards CLS?
- Yes. CLS covers the whole page lifetime and reports the worst session window. Only shifts within 500 ms of a click, tap or key press are excluded, so an infinite list that pushes the footer while the user scrolls still counts.
- Why is my CLS fine in Lighthouse but poor in the field?
- Lighthouse usually measures the initial load only, on one device profile. Real users scroll, wait for late scripts and see personalised content. Use field attribution to find the element that shifts.
Layout shifting after hydration?
I help teams running Next.js and WordPress front ends track down the components and fonts behind poor CLS and fix them without losing personalisation or streaming.