Why Core Web Vitals Matter Beyond SEO
Google uses Core Web Vitals as a ranking signal, which gives most teams the motivation to care about them. But the real value is user experience: a 1-second improvement in LCP increases conversion rates by 3-5% on e-commerce sites. A CLS score above 0.1 (elements shifting during load) increases bounce rates significantly. These metrics correlate with revenue, not just rankings.
Next.js has excellent built-in performance primitives. Most teams underuse them. This guide covers the highest-impact optimisations for each Core Web Vital in a Next.js App Router application.
LCP: Largest Contentful Paint
LCP measures how quickly the largest visible element (usually a hero image or heading) appears. Target under 2.5 seconds. The biggest LCP killers are unoptimised hero images and render-blocking resources.
Image Optimisation
import Image from 'next/image'
// Hero image — preload and eager load
sizes="100vw"
quality={85} // balance quality vs file size
/>
// Below-fold images — lazy load (default)
Always set priority on your LCP image. Without it, Next.js lazy-loads the image, which delays LCP by 500ms-2s on typical connections. Use modern formats: Next.js automatically serves WebP and AVIF to browsers that support them.
Font Loading
import { Inter } from 'next/font/google'
const inter = Inter({
subsets: ['latin'],
display: 'swap', // show fallback font while loading
preload: true,
variable: '--font-inter',
})
// In layout.tsx
next/font self-hosts Google Fonts with zero layout shift. It generates CSS font metrics that match the web font dimensions, so the fallback font takes exactly the same space — eliminating font-induced CLS entirely.
INP: Interaction to Next Paint
INP replaced FID as a Core Web Vital in 2024. It measures the time from user interaction (click, tap, keypress) to the next visual update. Target under 200ms. The main culprits are long JavaScript tasks that block the main thread.
// Break up long synchronous operations
async function processLargeList(items: Item[]) {
const results = []
for (let i = 0; i < items.length; i += 100) {
const chunk = items.slice(i, i + 100)
results.push(...chunk.map(process))
// Yield to allow browser to paint between chunks
await new Promise(r => setTimeout(r, 0))
}
return results
}
// Use startTransition for non-urgent state updates
import { startTransition } from 'react'
function handleInput(value: string) {
setInputValue(value) // urgent — update input immediately
startTransition(() => {
setSearchResults(search(value)) // non-urgent — can defer
})
}
CLS: Cumulative Layout Shift
CLS measures unexpected layout shifts. The most common causes: images without dimensions, dynamically injected content, and fonts loading and changing text height. Target under 0.1.
// Always specify aspect ratio for media
// Option 1: explicit width/height
// Option 2: aspect-ratio CSS for unknown dimensions
// Reserve space for dynamic content with skeleton loaders
function CommentSection({ postId }: { postId: string }) {
return (
}>
)
}
Bundle Optimisation
Analyse your bundle with @next/bundle-analyzer before optimising — guessing what is large is usually wrong.
// next.config.ts
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
})
module.exports = withBundleAnalyzer({})
// Run: ANALYZE=true npm run build
Common bundle bloat sources and fixes:
- date-fns: Import specific functions (
import { format } from 'date-fns') not the whole library - lodash: Use
lodash-eswith tree shaking, or replace with native JS equivalents - moment.js: Replace with
date-fnsordayjs— moment cannot be tree-shaken - Large icon libraries: Import individual icons, not the full set
Caching Strategy with ISR
Incremental Static Regeneration (ISR) serves pages as fast as static HTML while keeping content fresh. Use it for pages with data that changes infrequently.
// Revalidate every 60 seconds
export const revalidate = 60
// Or on-demand revalidation via API route
import { revalidatePath } from 'next/cache'
export async function POST(request: Request) {
const { path, secret } = await request.json()
if (secret !== process.env.REVALIDATION_SECRET) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
revalidatePath(path)
return Response.json({ revalidated: true })
}
Trigger on-demand revalidation from your CMS webhook when content changes. The first request after invalidation hits the server; all subsequent requests until the next invalidation serve cached HTML in microseconds.