Fix CLS in Next.js
Next.js provides excellent built-in tooling for CLS prevention, but it requires deliberate configuration. The most common source of CLS in Next.js apps is not using the next/image and next/font modules correctly -- specifically omitting required dimension props and skipping size-adjusted font fallbacks. This guide covers every significant CLS source in Next.js with precise code fixes.
Expected results
Before
0.38
CLS (Poor) -- standard img tags, Google Fonts via link tag, SSR hydration mismatches
After
0.02
CLS (Good) -- next/image with dimensions, next/font with fallbacks, stable Suspense skeletons
Step-by-step fix
Set dimensions on every next/image
The next/image component prevents CLS automatically -- but only if you provide width and height props. These tell the browser the image's aspect ratio before it loads, so it reserves the correct space. Without them, you get the same CLS as a bare <img> tag. For responsive images that fill their container, use the fill prop with a positioned parent instead.
// No dimensions = 0x0 until loaded = layout shift
<img src="/hero.jpg" alt="Hero" style={{ width: '100%' }} />
// next/image without dimensions also causes CLS
<Image src="/hero.jpg" alt="Hero" /> // Missing width+height
import Image from 'next/image';
// Option 1: Fixed dimensions (browser reserves exact space)
<Image
src="/hero.jpg"
alt="Hero"
width={1200}
height={630}
style={{ width: '100%', height: 'auto' }}
/>
// Option 2: fill prop for fully responsive images
<div style={{ position: 'relative', aspectRatio: '16/9' }}>
<Image
src="/hero.jpg"
alt="Hero"
fill
style={{ objectFit: 'cover' }}
sizes="(max-width: 768px) 100vw, 1200px"
/>
</div>
Replace Google Fonts links with next/font
Loading Google Fonts via a <link> tag causes FOUT (Flash of Unstyled Text) -- the browser renders fallback text first, then swaps to the web font, causing a layout shift when the metrics differ. The next/font module self-hosts fonts and automatically generates size-adjust, ascent-override, and descent-override values for the fallback font so the text reflow is imperceptible.
// In _document.tsx or layout.tsx -- causes FOUT and CLS
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap"
/>
// app/layout.tsx
import { Inter } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
display: 'swap',
variable: '--font-sans',
// next/font automatically calculates size-adjust for the fallback
// No CLS from font swap
});
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={inter.variable}>
<body className="font-sans">{children}</body>
</html>
);
}
Match Suspense fallback dimensions to loaded content
In the App Router, Suspense boundaries stream content progressively. If the fallback skeleton is a different height than the loaded content, the surrounding page shifts when the real content replaces the skeleton. Design skeleton UIs to match the loaded content's dimensions exactly.
// components/ProductCardSkeleton.tsx
// Skeleton must match the height of the real ProductCard
export function ProductCardSkeleton() {
return (
<div
style={{
height: '320px', // Same as loaded ProductCard
borderRadius: '8px',
background: 'var(--color-surface)',
animation: 'pulse 1.5s infinite',
}}
aria-hidden="true"
/>
);
}
// page.tsx
import { Suspense } from 'react';
import { ProductCard } from './ProductCard';
import { ProductCardSkeleton } from './ProductCardSkeleton';
export default function Page() {
return (
<Suspense fallback={<ProductCardSkeleton />}>
<ProductCard id="123" />
</Suspense>
);
}
Fix hydration mismatches
When server-rendered HTML does not match the initial client render, React corrects the DOM during hydration -- and these corrections can cause visible layout shifts. The most common causes are browser-only APIs used during render (window, localStorage), date/time values that differ between server and client timezones, and random IDs generated per-render.
// Bad: reads localStorage during render -- server has no localStorage
// This causes hydration mismatch and potential CLS
function ThemeToggle() {
const theme = localStorage.getItem('theme') ?? 'light'; // Error on server
return <button>{theme}</button>;
}
// Good: use useEffect to read browser-only values after hydration
function ThemeToggle() {
const [theme, setTheme] = useState('light'); // Consistent initial value
useEffect(() => {
// Only runs on client, after hydration -- no mismatch
setTheme(localStorage.getItem('theme') ?? 'light');
}, []);
return <button>{theme}</button>;
}
// Also good: suppress hydration warning for intentional differences
function ServerTime() {
return (
<time suppressHydrationWarning>
{new Date().toLocaleTimeString()}
</time>
);
}
Use transform for animations, not layout properties
Framer Motion (commonly used with Next.js) defaults to animating compositor-safe properties, but custom animations or CSS transitions that target height, margin, top, or left will trigger layout recalculation and CLS. Replace these with transform animations.
import { motion } from 'framer-motion';
// Bad: animating y position with top -- triggers layout
<motion.div animate={{ top: 0 }} initial={{ top: -20 }}>
Content
</motion.div>
// Good: y uses transform: translateY -- no layout shift
<motion.div animate={{ y: 0 }} initial={{ y: -20 }}>
Content
</motion.div>
// Good: opacity + scale -- both compositor-only
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.2 }}
>
Content
</motion.div>
Quick checklist
-
All
next/imageusage includeswidthandheight(orfillwith sized parent) -
Fonts loaded with
next/font(not manual<link>to Google Fonts) - Suspense skeletons match loaded content height
-
No browser-only APIs (
window,localStorage) accessed during initial render -
Animations use
transformandopacity, not layout-triggering properties -
Ad slots and dynamic banners have reserved
min-heightin the server-rendered HTML
Frequently asked questions
Hydration CLS occurs when server-rendered HTML differs from the initial client render. React corrects mismatches by updating the DOM, which can shift visible elements. Common causes: browser-only APIs used during render (window, localStorage), dynamic dates that differ between server/client timezones, and random IDs generated per-render. Use useEffect to isolate browser-only logic.
Yes, if you provide width and height props or use the fill prop with a positioned parent. Without dimensions, next/image cannot reserve space and will cause the same CLS as a bare <img> tag. Always include dimensions -- they are required in Next.js 13+ and will produce a build error if omitted.
Design skeleton fallbacks to match the loaded content's height exactly. If your product card is 320px tall, your skeleton should be 320px tall. A practical approach: add a fixed min-height to the Suspense wrapper that accounts for the loaded content, so the surrounding layout never collapses while data streams in.
styled-components can cause flash of unstyled content (FOUC) and resulting CLS if not configured for SSR. Use the ServerStyleSheet pattern or the Next.js styled-components compiler option. Tailwind CSS, as a globally imported utility stylesheet, does not cause CLS by itself. The risk with Tailwind is content-dependent classes being added/removed after render, which can cause dimensional changes.
Three approaches: (1) Enable Layout Shift Regions in Chrome DevTools Rendering panel for real-time blue highlights. (2) Add onCLS with attribution from the web-vitals library to log the shifting element and its previous/current rect. (3) Use the Performance panel to record a session and inspect Layout Shift entries in the Experience row -- each entry identifies the source element.
Set up real-user monitoring using the web-vitals JavaScript library (1.5KB). Send CLS data to your analytics platform (Google Analytics 4, custom endpoint). The attribution build identifies exactly which element caused each layout shift. For Nextjs, also monitor CLS after route transitions, as client-side navigation can trigger additional shifts not captured in initial page load.
Related resources
Complete CLS Guide
Deep dive into how CLS is calculated, measured, and optimized across any tech stack.
FixFix LCP in Next.js
Improve your Largest Contentful Paint with next/image priority, SSG, and Server Components.
FixFix INP in Next.js
Improve interaction responsiveness with React concurrent features and code splitting.
Continue learning
Complete CLS Guide
Deep dive into CLS -- thresholds, measurement, and optimization strategies.
FixFix LCP in Next.js
Related performance optimization for the same framework.
FixFix INP in Next.js
Related performance optimization for the same framework.
ToolCWV Score Explainer
Enter your scores for personalized fix recommendations.