/ โ† CLS Debugger Fixes / CLS in React
In this guide
  1. Why React apps shift differently
  2. Hydration mismatches
  3. Client-only components
  4. Layout decided in JavaScript
  5. Suspense, streaming and loading states
  6. Using the Next.js helpers correctly
  7. The fix, step by step
  8. Reporting CLS from the app
  9. Questions and answers

Why React apps shift differently

With server rendering, whether through Next.js, Remix, React Router or a custom setup, the browser first paints HTML generated on the server. Then React loads, hydrates that HTML and takes over. If the first client render produces exactly the same markup, nothing moves. Every difference between the two is a chance for a layout shift.

A purely client-rendered app, such as one built with Vite and no server rendering, shows an empty root first. Blank content cannot shift, but the app then renders in stages as data arrives, and each stage that pushes earlier content aside is counted.

There is one more difference. For Core Web Vitals, a single-page app is measured as one long page view. Client-side route changes do not start a new measurement, so a shift on the third screen a visitor opens adds to the same page's CLS. Shifts are grouped into session windows and the worst window is reported, so a bad transition can set the score even if the landing view was perfect.

React patternWhat movesSection below
Rendering null until mountedContent pops in after hydrationClient-only components
useMediaQuery or window.innerWidth in renderDesktop layout on first paint swaps to mobileLayout in JavaScript
Dates, random values or browser storage in renderServer HTML is thrown away and re-renderedHydration mismatches
Spinner as Suspense fallbackSmall fallback replaced by large contentSuspense and loading states
Raw <img> or CSS background images from a CMSImages open up when loadedNext.js helpers
Fonts from a stylesheet linkText reflows on font swapNext.js helpers
Runtime CSS-in-JS without server stylesUnstyled first paint, then styles applyCSS-in-JS

Hydration mismatches

When the markup React renders in the browser does not match the server HTML, React reports a hydration error. In development you see it in the console. In production, React recovers by discarding the server HTML for the affected part of the tree, up to the nearest Suspense boundary or the whole root, and rendering it again on the client. The visitor sees the page flash and often shift, because the re-rendered version may be a different size.

The usual causes:

Fix the source rather than silencing the warning. suppressHydrationWarning only covers text content of a single element, such as a timestamp, and does nothing for structural differences. For values that must differ, render a stable value on the server (for example a date formatted in a fixed time zone) and update it in an effect, keeping the same element size.

Client-only components

A very common pattern is to wait until the component has mounted before showing anything:

function StockBadge({ sku }) {
  const [mounted, setMounted] = useState(false);
  useEffect(() => setMounted(true), []);
  if (!mounted) return null;              // nothing on the server
  return <p className="stock">{getLocalStock(sku)}</p>;
}

This avoids a hydration error but guarantees a shift: the server HTML has nothing, and after hydration a paragraph appears and pushes the content below. The fix is to render something the same size on the server:

if (!mounted) return <p className="stock stock--pending" aria-hidden="true"></p>;

/* CSS */
.stock { min-height: 1.5em; margin: 0 0 12px; }

The same applies to components loaded with next/dynamic and ssr: false, or with React.lazy. Pass a loading component (or a Suspense fallback) that occupies the final space. A chat launcher or other element in a fixed position can safely appear late, because it does not sit in the page flow.

Layout decided in JavaScript

Hooks that read the window size during render are one of the largest sources of React CLS. The server has no window, so it renders a default, usually the desktop layout. A phone then hydrates, the hook reports a narrow screen, and the component switches to its mobile layout: a different navigation, a stacked grid, a shorter hero. Everything moves.

// Shifts on phones: server always renders the desktop branch
const isMobile = useMediaQuery('(max-width: 768px)');
return isMobile ? <MobileNav /> : <DesktopNav />;

Move the decision to CSS, which the browser applies before first paint:

<MobileNav className="nav-mobile" />
<DesktopNav className="nav-desktop" />

.nav-mobile { display: none; }
@media (max-width: 768px) {
  .nav-mobile { display: block; }
  .nav-desktop { display: none; }
}

Rendering both costs a little extra HTML but removes the swap. Keep JavaScript breakpoints for behaviour that does not change layout, such as whether to attach a swipe handler. The same applies to masonry grids and "fit to container" components that measure elements in an effect: use CSS grid, aspect-ratio and container queries where you can.

Suspense, streaming and loading states

A Suspense boundary shows its fallback while the content inside it is loading, then swaps the content in. The swap is a normal DOM change: if the fallback is 48 pixels tall and the content is 600, the content below moves 552 pixels.

๐Ÿ’ก When a visitor clicks a link, shifts within 500 milliseconds of the click are excluded. A route transition that takes longer, because it waits for data, can produce shifts that count. An immediately rendered, correctly sized loading state keeps the change inside the exempt window.

Using the Next.js helpers correctly

next/image

The Image component requires width and height props unless you statically import the file (in which case Next.js reads the dimensions at build time) or use the fill prop. Those numbers set the aspect ratio of the reserved box; the image can still be displayed responsively. With fill, the image takes the size of its parent, so the parent needs position: relative and a size of its own, typically from aspect-ratio. A fill image inside a parent with no height is a zero-height box that jumps open. For images from a CMS, request the width and height from the API and pass them through.

next/font

next/font downloads Google fonts at build time and serves them from your own domain, and supports local font files. By default it uses display: 'swap' and generates a fallback font adjusted with size-adjust and related descriptors (the adjustFontFallback option), so the swap moves text very little. The benefit is lost if the same family is also loaded with a <link> to Google Fonts or an @import in global CSS, so remove those. Font behaviour in general is covered in the web fonts guide.

CSS-in-JS

Libraries that generate styles at runtime must also collect them on the server and include them in the HTML. If they do not, the first paint is unstyled or partly styled, and the page jumps as styles are injected. With the App Router this means setting up the library's style registry; with the Pages Router, a custom document. Zero-runtime options such as CSS Modules or Tailwind avoid the issue.

The fix, step by step

Step 1

Measure CLS across route changes

Run the CLS Debugger on your key landing routes, then record a Performance trace in Chrome DevTools with CPU throttling while you load the page and click through two or three client-side navigations. Look at the Layout shifts track for each stage and note the moved nodes. Because a single-page app is scored as one long visit, shifts during transitions matter as much as those during the first load.

Step 2

Remove hydration mismatches

Run a production build locally and check the console for hydration errors, then fix each source: move Date, random values, locale formatting and browser storage reads out of render and into effects, render a stable server value in the meantime, and correct invalid nesting such as block elements inside paragraphs. Do not use suppressHydrationWarning as a general fix, because React still re-renders mismatched structure on the client.

Step 3

Give client-only components a same-size placeholder

Search the codebase for components that return null until mounted, and for next/dynamic or React.lazy imports. For each one that sits in the page flow, render a placeholder element with the same dimensions on the server and during loading, using a min-height or aspect-ratio class. Components that are fixed or absolutely positioned, such as chat launchers, can stay as they are because they do not push content.

Step 4

Move layout decisions from JavaScript to CSS

Find every use of useMediaQuery, window.innerWidth, resize observers and element measurement that changes what is rendered. Replace layout switching with CSS media queries or container queries, rendering both variants and hiding one with CSS where necessary. Replace JavaScript masonry and fit-to-width code with CSS grid and aspect-ratio. Keep JavaScript for behaviour only, so the server HTML and the hydrated page have the same shape on every device.

Step 5

Size Suspense and loading fallbacks

Review every Suspense boundary, loading.tsx file and loading state in data-fetching components. Replace spinners and one-line messages with skeletons that match the final layout at each breakpoint, including the number of items, card heights and gaps. Where content can be fetched on the server instead of in an effect, move it there, so the first HTML already contains the data and there is nothing to swap in later.

Step 6

Use next/image and next/font properly

Replace raw <img> tags for content images with the Image component, passing real width and height or using fill inside a parent with an aspect-ratio. Load every font through next/font and delete leftover Google Fonts links and CSS imports. If you use runtime CSS-in-JS, set up server style collection so the first paint is fully styled. In other React frameworks, apply the same rules by hand.

Step 7

Collect CLS from real users

Add the attribution build of the web-vitals library, or the useReportWebVitals hook in Next.js, and send each CLS value with the element behind its largest shift to your analytics. Include the route and device type. This shows shifts that only happen on certain journeys, screen sizes or slow devices, which lab tests miss. Watch the Core Web Vitals report in Search Console over the following weeks as well.

Reporting CLS from the app

import { onCLS } from 'web-vitals/attribution';

onCLS(({ value, attribution }) => {
  navigator.sendBeacon('/analytics/cls', JSON.stringify({
    value,
    target: attribution.largestShiftTarget,
    path: location.pathname,
  }));
});

The CLS value can be reported more than once as the visit continues, and the final report usually happens when the tab is hidden. Keep the latest value per visit rather than adding them up. Group results by route and by the reported target selector: a single component often turns out to be behind most of the poor scores.

Questions and answers

1. Does Next.js prevent CLS automatically?

It helps but does not guarantee it. The Image component forces you to provide dimensions or a sized parent, and the font loader generates an adjusted fallback font, which remove two common causes. But Next.js cannot stop a component that renders nothing until mounted, a layout chosen with a media query hook, a loading file that is smaller than the page it stands in for, or a third-party widget that injects content. Those need fixing in your own code.

2. Why does my React page shift only on mobile?

A common reason is layout decided in JavaScript. The server has no screen size, so a hook such as useMediaQuery returns its default, usually the desktop value, and the server HTML contains the desktop layout. On a phone, React hydrates, the hook reports a narrow screen, and the component re-renders as the mobile version. Moving that decision into CSS media queries means the browser shows the right layout from the first paint.

3. Do client-side route changes count towards CLS?

Yes. Core Web Vitals treat a single-page app as one long page view, so shifts that happen after a client-side navigation are added to the same page load and grouped into session windows with the rest. Shifts within half a second of the click that started the navigation are excluded, but transitions that wait for data before changing the page can produce shifts that count. Sized loading states that appear straight away avoid that.

4. Is suppressHydrationWarning a fix for layout shift?

No. It tells React not to warn about a text content mismatch on one element, which is useful for something like a timestamp that is expected to differ. It does not stop the text from changing, so if the new text is a different length it can still move things. It also does nothing for mismatched elements or structure, where React re-renders part of the tree on the client. Fix the cause of the mismatch instead.

5. What should a Suspense fallback look like to avoid CLS?

It should take up the same space as the content it stands in for. For a product list that means a grid of placeholder cards with the same number of columns, the same card height and the same gaps at each breakpoint. For a text section it means blocks at roughly the height of the finished text. A spinner or short loading message is fine only where the real content is equally small or sits in a fixed-size container.

6. How do I use next/image with images of unknown size?

You have two options. If your CMS or image service can tell you the width and height, fetch them with the image address and pass them as props, which is the most accurate. If not, use the fill prop and put the image inside a parent element that has position relative and a fixed aspect ratio, such as 4 by 3, with object-fit cover on the image. The box is reserved and the image is cropped to fit.

Check your React pages for layout shift

The CLS Debugger records each layout shift as your page loads, names the elements that moved, calculates the cumulative score and ranks the shifts by impact, giving you the components to look at first.

Open CLS Debugger Fixes โ†’
๐Ÿ’ฌ Got a problem?
Related Guides: All CLS Debugger Fixes  ·  CLS from Late-Loaded Components  ·  Core Web Vitals in Next.js  ·  CLS from Web Fonts  ·  Learning Hub

About aiwebpageseo

aiwebpageseo.com is a data-driven SEO and AEO (Answer Engine Optimisation) platform providing a free suite of technical SEO tools. Rather than relying on AI-theorised assumptions, the platform analyses live URL performance, delivering objective diagnostics, page speed metrics, CLS debugging, and site crawl data alongside actionable technical tutorials.