Optimizing CLS and INP
In this lesson, we'll focus on preventing Cumulative Layout Shift (CLS) and improving Interaction to Next Paint (INP) in Next.js applications.
Preventing Layout Shift (CLS)
Layout shifts frustrate users and hurt your CLS score. The key is reserving space for dynamic content.
Image Sizing
Always specify dimensions to prevent shifts:
// Method 1: Explicit dimensions
<Image
src="/photo.jpg"
alt="Product photo"
width={800}
height={600}
/>
// Method 2: Fill with sized container
<div style={{ position: 'relative', width: '100%', height: '400px' }}>
<Image
src="/photo.jpg"
alt="Product photo"
fill
style={{ objectFit: 'cover' }}
/>
</div>
// Method 3: Aspect ratio container
<div style={{ position: 'relative', aspectRatio: '16/9' }}>
<Image
src="/photo.jpg"
alt="Product photo"
fill
style={{ objectFit: 'cover' }}
/>
</div>
Reserve Space for Dynamic Content
// Bad: Height changes when content loads
function AdBanner() {
const [ad, setAd] = useState(null)
useEffect(() => {
loadAd().then(setAd)
}, [])
if (!ad) return null // No space reserved!
return <div>{ad}</div>
}
// Good: Reserve space with min-height
function AdBanner() {
const [ad, setAd] = useState(null)
useEffect(() => {
loadAd().then(setAd)
}, [])
return (
<div style={{ minHeight: '250px' }}>
{ad ? <div>{ad}</div> : <Skeleton />}
</div>
)
}
Skeleton Loading States
function ProductCard({ product }: { product: Product | null }) {
if (!product) {
return (
<div className="product-card">
<div className="skeleton skeleton-image" />
<div className="skeleton skeleton-text" />
<div className="skeleton skeleton-text short" />
</div>
)
}
return (
<div className="product-card">
<Image
src={product.image}
alt={product.name}
width={300}
height={200}
/>
<h3>{product.name}</h3>
<p>{product.price}</p>
</div>
)
}
Avoid Inserting Content Above
// Bad: Banner appears at top, pushing content down
function Page() {
const [showBanner, setShowBanner] = useState(false)
useEffect(() => {
checkPromotion().then(show => setShowBanner(show))
}, [])
return (
<div>
{showBanner && <PromoBanner />} {/* Causes shift! */}
<MainContent />
</div>
)
}
// Good: Reserve space or use fixed positioning
function Page() {
const [showBanner, setShowBanner] = useState(false)
return (
<div>
<div style={{ minHeight: showBanner ? '60px' : '0' }}>
{showBanner && <PromoBanner />}
</div>
<MainContent />
</div>
)
}
Font Loading and FOUT
Use CSS font-display and matching fallback metrics:
const inter = Inter({
subsets: ['latin'],
display: 'swap',
adjustFontFallback: true, // Adjusts fallback to match
})
Improving INP (Responsiveness)
INP measures how quickly the page responds to interactions. The goal is under 200ms.
Minimize Main Thread Work
Long tasks block the main thread, causing slow interactions.
// Bad: Heavy computation on click
function handleClick() {
const result = heavyComputation(data) // Blocks main thread
setResult(result)
}
// Good: Defer to next frame or use Web Worker
function handleClick() {
requestIdleCallback(() => {
const result = heavyComputation(data)
setResult(result)
})
}
Use Transitions for Non-Urgent Updates
import { useTransition } from 'react'
function SearchResults() {
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
const [isPending, startTransition] = useTransition()
function handleChange(e) {
const value = e.target.value
setQuery(value) // Urgent: update input
startTransition(() => {
// Non-urgent: can be deferred
setResults(filterResults(value))
})
}
return (
<>
<input value={query} onChange={handleChange} />
{isPending && <Spinner />}
<ResultsList results={results} />
</>
)
}
Minimize Client Components
Push Client Components down the tree:
// Good: Server Component with small Client Component child
export default async function ProductsPage() {
const products = await fetchProducts()
return (
<div>
<h1>Products</h1>
<SearchFilter /> {/* Only this is Client Component */}
<ProductList products={products} /> {/* Server Component */}
</div>
)
}
Debounce Frequent Events
import { useDebouncedCallback } from 'use-debounce'
function Search() {
const [query, setQuery] = useState('')
const debouncedSearch = useDebouncedCallback(
(value) => {
performSearch(value)
},
300 // Wait 300ms after last keystroke
)
return (
<input
value={query}
onChange={(e) => {
setQuery(e.target.value)
debouncedSearch(e.target.value)
}}
/>
)
}
Optimize Event Handlers
// Bad: Heavy handler
function List({ items }) {
return (
<ul>
{items.map(item => (
<li
key={item.id}
onClick={() => {
// Creates new function on every render
heavyOperation(item)
}}
>
{item.name}
</li>
))}
</ul>
)
}
// Good: Memoized handler
function List({ items }) {
const handleClick = useCallback((item) => {
heavyOperation(item)
}, [])
return (
<ul>
{items.map(item => (
<li key={item.id} onClick={() => handleClick(item)}>
{item.name}
</li>
))}
</ul>
)
}
Measuring Performance
Web Vitals in Next.js
// app/layout.tsx
import { SpeedInsights } from '@vercel/speed-insights/next'
export default function RootLayout({ children }) {
return (
<html>
<body>
{children}
<SpeedInsights />
</body>
</html>
)
}
Custom Web Vitals Reporting
'use client'
import { useReportWebVitals } from 'next/web-vitals'
export function WebVitals() {
useReportWebVitals((metric) => {
console.log(metric)
// Send to your analytics
})
return null
}
Performance Checklist
| Item | Check |
|---|---|
| Images have dimensions | |
| Dynamic content has reserved space | |
| Skeletons used for loading states | |
| Fonts use display: swap | |
| Heavy components use dynamic import | |
| Client Components minimized | |
| Event handlers optimized | |
| Transitions used for non-urgent updates |
Summary
In this lesson, you learned:
- Preventing CLS with image sizing
- Reserving space for dynamic content
- Using skeleton loading states
- Avoiding content insertion above existing content
- Improving INP with main thread optimization
- Using transitions for non-urgent updates
- Minimizing and optimizing Client Components
- Measuring performance with Web Vitals
In the final module, we'll cover advanced SEO topics including internationalization and monitoring.

