CLS Next.js 14+

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.

JSX -- Before (CLS)
// 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
JSX -- After (No CLS)
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.

TypeScript -- Before (CLS from font swap)
// 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"
/>
TypeScript -- After (Zero CLS)
// 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.

TypeScript -- Stable Suspense skeleton
// 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.

TypeScript -- Fixing browser-only values
// 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.

TypeScript -- Safe Framer Motion 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/image usage includes width and height (or fill with 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 transform and opacity, not layout-triggering properties
  • Ad slots and dynamic banners have reserved min-height in 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.

Continue learning