Next.js Core Web Vitals: Native Optimizations and Where They Fall Short
Next/Image, Server Components, and Streaming give you CWV defaults out of the box. Here are the edge cases where you still have to tune.
Next.js gives you a strong Core Web Vitals baseline without much effort. next/image handles responsive sizing and WebP conversion automatically. Server Components mean your page ships less JavaScript by default. next/font eliminates layout shift from font swaps. Streaming with Suspense makes slow data fetches feel faster to the user.
But "Next.js handles CWV" is only half true. The framework gives you the right tools — it doesn't prevent you from misusing them. The 'use client' directive, placed carelessly, can ship hundreds of kilobytes of third-party JS that tanks your INP score. A next/image with priority in the wrong place, or without it where it's needed, causes LCP regressions. Heavy event handlers in client components stall the main thread.
Here's what Next.js handles for you, and where you still need to tune.
What Next.js Handles Automatically
next/image
The next/image component does several things that would otherwise require manual work:
- Serves WebP/AVIF instead of JPEG/PNG, reducing transfer size 30–50%
- Generates responsive
srcsetautomatically — the right size for the viewport - Lazy loads images below the fold by default
- Reserves space in the DOM to prevent CLS (requires
widthandheightprops orfilllayout)
The single most important prop for LCP: priority on your above-the-fold hero image.
// This image is your LCP element — tell Next.js to preload it
<Image
src="/hero.jpg"
alt="Hero image"
width={1200}
height={600}
priority
/>
Without priority, Next.js lazy-loads the image. On a page where the hero is the LCP element, this delays the metric significantly. priority adds a <link rel="preload"> in the document head.
Server Components and Reduced Client JS
Server Components don't ship any JavaScript to the client. A page that fetches data, renders HTML, and returns it — with no interactivity — ships zero JS for that component. This reduces your Total Blocking Time and improves INP by giving the main thread less to do.
The baseline JavaScript for a Next.js app (React runtime, router, prefetching) is around 70–90kb gzipped. Add components thoughtfully and you can build pages that stay close to this baseline.
next/font
Font swaps cause CLS. A fallback system font loads, text renders at one size, the custom font loads, and elements shift to accommodate the new font metrics. next/font eliminates this by:
- Self-hosting fonts at build time (no third-party DNS lookup)
- Injecting
size-adjuston the fallback font to match the metrics of the actual font - Using
font-display: optionalorfont-display: swapdepending on your config
// app/layout.tsx
import { Inter } from 'next/font/google'
const inter = Inter({
subsets: ['latin'],
display: 'swap',
variable: '--font-inter',
})
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={inter.variable}>
<body>{children}</body>
</html>
)
}
Use display: 'optional' for the best CLS scores — this tells the browser to skip the font swap entirely if the custom font hasn't loaded within 100ms. The downside: users on slow connections always see the fallback. display: 'swap' is the middle ground.
Streaming with Suspense
Streaming lets Next.js send the page shell immediately and stream in slower data as it resolves. For pages with database queries, this improves Time to First Byte and perceived performance:
// app/dashboard/page.tsx
import { Suspense } from 'react'
import { UserStats } from './user-stats'
import { RecentActivity } from './recent-activity'
export default function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<StatsSkeleton />}>
<UserStats />
</Suspense>
<Suspense fallback={<ActivitySkeleton />}>
<RecentActivity />
</Suspense>
</div>
)
}
The page shell renders immediately. UserStats and RecentActivity stream in as their data resolves. LCP is typically the page shell or a hero element — streaming keeps this fast even when data is slow.
Where Next.js Falls Short
Large Client Bundles from 'use client'
'use client' makes a component (and all its imports) run on the client. The problem: it's easy to add a lightweight interactive component and accidentally pull in a large dependency through its imports.
Diagnose with @next/bundle-analyzer:
npm install @next/bundle-analyzer
// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
})
module.exports = withBundleAnalyzer({})
ANALYZE=true npm run build
Look for large client-side chunks. Common offenders:
- Date libraries (moment.js is 72kb gzipped — use
date-fnsor nativeIntl) - Rich text editors pulled in through a form component
- Chart libraries that could be server-rendered static images
lodashimported asimport _ from 'lodash'instead ofimport debounce from 'lodash/debounce'
The fix pattern: push 'use client' as far down the component tree as possible. Keep data fetching and layout in Server Components; only wrap the specific interactive element in a Client Component.
// Bad: the whole page is a client component because of one button
'use client'
export default function BlogPost({ post }) {
const [liked, setLiked] = useState(false)
return (
<article>
{/* 1000 lines of static content */}
<button onClick={() => setLiked(!liked)}>Like</button>
</article>
)
}
// Good: only the button is a client component
// app/blog/[slug]/page.tsx (Server Component)
import { LikeButton } from './like-button'
export default async function BlogPost({ params }) {
const post = await getPost(params.slug)
return (
<article>
{/* static content — server rendered, no JS shipped */}
<LikeButton postId={post.id} />
</article>
)
}
// app/blog/[slug]/like-button.tsx
'use client'
export function LikeButton({ postId }: { postId: string }) {
const [liked, setLiked] = useState(false)
return <button onClick={() => setLiked(!liked)}>Like</button>
}
INP Regressions from Heavy Event Handlers
INP measures the latency from user interaction to the next visual update. The main thread must be available to respond. Long-running synchronous work in event handlers kills INP scores.
Common causes in Next.js apps:
- Filtering a large list synchronously in a
onChangehandler - Running layout calculations inside a
useEffecttriggered by user input - Third-party analytics scripts that run synchronously on click
Diagnosis: open Chrome DevTools, go to Performance, record a session while interacting with the page, and look for "Long tasks" (tasks over 50ms shown in red). The culprit is usually visible.
Fixes:
// Instead of synchronous filter on keydown:
const filtered = items.filter(item => item.name.includes(query))
// Use useDeferredValue to defer expensive computation:
import { useDeferredValue, useMemo } from 'react'
function SearchableList({ items }) {
const [query, setQuery] = useState('')
const deferredQuery = useDeferredValue(query)
const filtered = useMemo(
() => items.filter(item => item.name.includes(deferredQuery)),
[items, deferredQuery]
)
return (
<>
<input value={query} onChange={e => setQuery(e.target.value)} />
<ul>{filtered.map(item => <li key={item.id}>{item.name}</li>)}</ul>
</>
)
}
For deeper INP optimization in Next.js, see How to Fix INP in Next.js.
LCP Regressions from Misplaced priority
The priority prop should go on exactly one image per page: the one that's the LCP element. Common mistakes:
priorityon a logo in the header when the hero image below it is actually the LCP elementprioritymissing on a hero image because it's rendered conditionally or inside a Suspense boundarypriorityon a carousel image that may or may not be visible in the initial viewport
Use PageSpeed Insights or Chrome DevTools' Performance tab to identify your actual LCP element before placing priority. You want it on the image that Google identifies as the LCP element for your page.
The Optimization Workflow
-
Measure first. Run your page through PageSpeed Insights. Note the specific metric that's failing and the element cited (for LCP) or interaction cited (for INP).
-
Check bundle size. Run
ANALYZE=true npm run buildand look for unexpectedly large client chunks. Push'use client'boundaries down the tree. -
Verify
priorityplacement. Confirm that the LCP element (as identified by PageSpeed Insights) has thepriorityprop. Remove it from non-LCP images. -
Check for CLS. Ensure all
next/imageusage includes explicitwidthandheightor uses thefillprop with a sized container. Check thatnext/fontis configured and the fallback font has appropriatesize-adjust. -
Profile INP. Use Chrome DevTools' Performance tab to find long tasks during interactions. Use
useDeferredValueoruseTransitionfor expensive state updates. -
Run a full audit. After optimizations, run an 80-check audit on RankCrab to verify the technical SEO layer is solid — CWV improvements don't matter if the page can't be crawled.
This article is part of the SEO for Next.js cluster. Related reading: