Next.js is great. It is genuinely good at a lot of things. But it is also very easy to build a Next.js app that has terrible Core Web Vitals, because the framework gives you a lot of power and several of those powers fire directly at your own foot if you are not careful.
This guide is for React and Next.js developers specifically. If you are on WordPress or Shopify, the other guides have more relevant advice for your stack. But if you are using App Router, Pages Router, or somewhere in between, and your CWV are struggling, read on.
The Most Common Next.js CWV Mistakes
Before we get into the fixes, here is a list of the patterns that cause problems most often. You will probably recognize some of these.
- Using
'use client'on components that do not need interactivity - Using a regular
<img>tag instead ofnext/image - Forgetting the
priorityprop on the above-fold image - Importing heavy libraries at the top of client components
- Triggering large React re-renders on every interaction
- Not using
next/font(using Google Fonts CSS links instead)
Let us go through each one with fixes.
next/image: The LCP Lifesaver (When Used Correctly)
The next/image component handles WebP conversion, responsive srcsets, and lazy loading automatically. But it has two critical props that developers often miss.
The priority Prop
This is the single most common Next.js LCP mistake. If your above-fold image does not have priority, Next.js lazy-loads it by default. That means the browser waits until it parses your component tree before it even starts downloading your hero image. Your LCP tanks.
// Wrong: hero image gets lazy-loaded, LCP suffers
<Image
src="/hero.webp"
alt="Hero image"
width={1200}
height={630}
/>
// Right: browser fetches this immediately
<Image
src="/hero.webp"
alt="Hero image"
width={1200}
height={630}
priority
/>
Rule: any image visible above the fold on first load should have priority. Usually that is one image per page, two at most. Do not add priority to everything or you lose the benefit.
The sizes Prop for Responsive Images
Without sizes, Next.js generates a default srcset but cannot optimize for different viewport sizes. With sizes, it can serve appropriately sized images for phones versus desktops:
<Image
src="/hero.webp"
alt="Hero"
fill
priority
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 1200px"
/>
This tells Next.js that on mobile the image fills the full viewport width, on tablet it fills 80%, and on desktop it is 1200px. Next.js uses this to generate an appropriate srcset. Mobile users download a much smaller image.
blur Placeholder for CLS Prevention
Images loading after the page renders can cause layout shift. The blur placeholder reserves space immediately with a low-res placeholder, preventing CLS:
// With local images: automatic blurDataURL
import heroImg from '../public/hero.webp';
<Image
src={heroImg}
alt="Hero"
placeholder="blur" // Next.js generates blurDataURL automatically
priority
/>
// With remote images: provide blurDataURL manually
<Image
src="https://cdn.example.com/hero.webp"
alt="Hero"
width={1200}
height={630}
placeholder="blur"
blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRgAB..."
/>
next/font: Stop FOUT and CLS From Fonts
If you are still using this in your app:
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap" />
Switch to next/font immediately. It self-hosts the font, eliminates the external request, and applies font-display: optional by default (which prevents CLS). The difference in CLS score can be dramatic.
// app/layout.tsx
import { Inter } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
display: 'swap', // Or 'optional' to prevent FOUT entirely
variable: '--font-inter',
});
export default function RootLayout({ children }) {
return (
<html lang="en" className={inter.variable}>
<body>{children}</body>
</html>
);
}
What this does: Next.js downloads the font at build time, hosts it on your own domain, inlines the font-face CSS so there is no extra request, and applies the display strategy you choose. No flash, no shift, no external dependency at runtime.
React Server Components vs Client Components: CWV Impact
Here is the thing about 'use client' that many developers do not fully understand. When you mark a component as a client component, it does not just make that component interactive. It also sends all of its JavaScript to the browser. Including the JavaScript for all of its imports.
// This ships React runtime + lodash + chart.js + your component to the browser
// even if the user never interacts with it
'use client';
import _ from 'lodash';
import { BarChart } from 'chart.js';
export function StatsSection({ data }) {
return <BarChart data={_.groupBy(data, 'category')} />;
}
If that component is above the fold, all that JavaScript parsing competes with rendering your LCP element. INP also suffers because a larger JS bundle means more hydration work when the user first interacts with the page.
The fix: keep client components as leaves, not roots. Move state down to the smallest component that needs it. Let parent components be server components that render fast HTML with no JavaScript cost.
Code Splitting for INP: next/dynamic in Practice
Heavy components like rich text editors, chart libraries, or complex modals should not be in your initial JavaScript bundle. Use next/dynamic to load them only when needed:
import dynamic from 'next/dynamic';
// Only loads when the component is rendered
const RichTextEditor = dynamic(
() => import('./RichTextEditor'),
{
loading: () => <div style={{height: '300px', background: '#f1f5f9'}} />,
ssr: false, // Editor only runs in browser
}
);
export function BlogPost({ post }) {
const [editing, setEditing] = useState(false);
return (
<div>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
{editing && <RichTextEditor value={post.content} />}
</div>
);
}
That loading prop is important for CLS. It reserves space with a fixed-height placeholder while the component loads. Without it, the editor loads and shifts everything below it down.
App Router vs Pages Router: CWV Reality Check
If you are deciding between App Router and Pages Router, here is the honest CWV comparison:
| Factor | App Router | Pages Router |
|---|---|---|
| LCP potential | Better (RSC reduces JS) | Good but more JS to client |
| CLS risk | Higher (streaming can shift) | Lower if done right |
| INP | Better (less client JS) | Depends on hydration |
| Learning curve | Steep | Familiar patterns |
| CWV footguns | More (streaming CLS, excessive 'use client') | Fewer but heavier hydration |
App Router has better CWV ceiling if used correctly. But it also has more ways to accidentally hurt your CWV if you over-use client components or mismanage Suspense boundaries. Pages Router is more predictable but harder to get excellent CWV on complex apps.
Quick Next.js CWV Checklist
Next.js Performance Checklist
- ✓ Hero image uses
next/imagewithpriorityprop - ✓ All images have
sizesprop for responsive optimization - ✓ Fonts loaded via
next/font, not Google Fonts CDN - ✓
'use client'only on components that actually need interactivity - ✓ Heavy libraries loaded with
next/dynamic - ✓ Dynamic components have loading state with fixed height (prevents CLS)
- ✓ Suspense boundaries have fallbacks with reserved space
- ✓ Server components used for data fetching where possible
- ✓ Bundle analyzer run to check for unexpected large dependencies
- ✓ PSI field data checked, not just Lighthouse lab score
FAQ
Does Turbopack improve Core Web Vitals?
Turbopack improves build speed and hot module replacement, not runtime CWV. It makes development faster and can produce slightly smaller bundles in some cases, but it does not directly change what your users experience. Your CWV are determined by what ships to the browser, not how fast it was compiled.
Should I use Vercel for better CWV?
Vercel's edge network genuinely helps TTFB for users far from your server. If you are self-hosting in one region, users in other continents see TTFB of 500ms or more. Vercel's CDN reduces that to under 100ms globally. That directly improves LCP. It is not magic, it is just a good CDN. Cloudflare does the same thing for less money if you self-host.
My Lighthouse score is 95 but CrUX field data is failing. What is wrong?
This is the lab vs field gap in action. Your Lighthouse test runs under controlled conditions. Real users have slower devices, slower connections, more browser extensions, more tabs open. Check which specific metric is failing in PSI field data, then profile that metric on a throttled mobile device in DevTools.
Related Performance Guides
Still Getting Red Scores?
Run a free audit and get a punch-list of exactly what to fix. No account needed.
Run Free Audit →Still Getting Red Scores?
Run your site through VitalsFixer. Free audit in 30 seconds, no account needed.
Analyze My Site Free →Want an Expert to Handle It?
Real engineers, 48-hour turnaround, money back if scores don't improve.
View Expert Fix →