Fixing Layout Shift in React and Next.js
React sites shift for reasons that plain HTML sites do not have. The server sends one version of the page, the browser runs the app and sometimes draws a different one, and anything that only appears after hydration pushes the rest aside.
Loading fallbacks that are the wrong size, layouts chosen in JavaScript rather than CSS, and images or fonts loaded outside the framework helpers add to it. This guide is for developers working in React, including Next.js. It explains each React-specific cause, the code change that removes it, and how to measure CLS across client-side navigations.
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 pattern | What moves | Section below |
|---|---|---|
Rendering null until mounted | Content pops in after hydration | Client-only components |
useMediaQuery or window.innerWidth in render | Desktop layout on first paint swaps to mobile | Layout in JavaScript |
| Dates, random values or browser storage in render | Server HTML is thrown away and re-rendered | Hydration mismatches |
| Spinner as Suspense fallback | Small fallback replaced by large content | Suspense and loading states |
Raw <img> or CSS background images from a CMS | Images open up when loaded | Next.js helpers |
| Fonts from a stylesheet link | Text reflows on font swap | Next.js helpers |
| Runtime CSS-in-JS without server styles | Unstyled first paint, then styles apply | CSS-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:
- Values that differ by time or place.
new Date(),Date.now(),Math.random()andtoLocaleString()give different results on the server and in the visitor's browser, which may be in a different time zone or locale. - Browser-only checks in render.
typeof window !== 'undefined'branches,localStoragereads and cookie reads in the render path produce one output on the server and another on the client. - Invalid HTML nesting. A
<div>inside a<p>, or a link inside a link, is corrected by the browser's parser before React sees it, so the DOM no longer matches. - Browser extensions and injected scripts that add elements before hydration. You cannot control these, but they are worth ruling out when a mismatch only happens on one machine.
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.
- Make fallbacks skeletons of the right size. A product grid fallback should be a grid of placeholder cards at the real card height, per breakpoint, not a centred spinner.
- Place boundaries around fixed-size regions. A boundary that wraps a sidebar widget with a known height is easy to reserve. One that wraps half the page is not.
- App Router loading files. In Next.js,
loading.tsxrenders while a route segment streams. Build it to mirror the page's layout, with the heading, image area and text blocks in the same positions. - Data fetched in effects. Components that fetch in
useEffectrender an empty or loading state first. Give that state the final dimensions, or fetch on the server so the first HTML already contains the data.
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.
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.
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 โ