Learn how to optimize Core Web Vitals in Next.js 14/15 App Router applications with practical techniques for LCP, INP, and CLS.
Prerequisites
- Next.js 14+ project with App Router
- Basic understanding of React Server Components
- Vercel deployment (or similar)
Step 1: Optimize LCP with Next.js Image Component
import Image from 'next/image';
export default function Hero() {
return (
<div className="hero">
<Image
src="/hero.jpg"
alt="Hero image"
width={1200}
height={630}
priority // Preload this image
sizes="100vw"
className="hero-image"
/>
</div>
);
}
Image Optimization Configuration
// next.config.js
module.exports = {
images: {
formats: ['image/avif', 'image/webp'],
deviceSizes: [640, 750, 828, 1080, 1200, 1920],
imageSizes: [16, 32, 48, 64, 96, 128, 256],
},
};
Step 2: Optimize Font Loading
// app/layout.js
import { Inter } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
display: 'swap',
variable: '--font-inter',
});
export default function RootLayout({ children }) {
return (
<html lang="en" className={inter.variable}>
<body className={inter.className}>{children}</body>
</html>
);
}
Next.js automatically:
- Inlines font CSS
- Preloads font files
- Uses
font-display: swap - Eliminates layout shift from font loading
Step 3: Reduce INP with Server Components
// Move heavy computations to server
// app/articles/page.js
async function ArticlesPage() {
// This runs on the server - zero client JS
const articles = await fetchArticles();
const processedData = heavyComputation(articles);
return (
<div>
{processedData.map(article => (
<ArticleCard key={article.id} article={article} />
))}
</div>
);
}
Client Component Optimization
'use client';
import { useTransition, useMemo } from 'react';
export function SearchResults({ query }) {
const [isPending, startTransition] = useTransition();
const filteredResults = useMemo(() => {
return expensiveFilter(query);
}, [query]);
return (
<div style={{ opacity: isPending ? 0.7 : 1 }}>
{filteredResults.map(result => (
<ResultCard key={result.id} result={result} />
))}
</div>
);
}
Step 4: Prevent CLS with Streaming SSR
// app/page.js
import { Suspense } from 'react';
export default function Page() {
return (
<main>
<HeroSection /> {/* Static, renders immediately */}
<Suspense fallback={<ProductSkeleton />}>
<ProductList /> {/* Dynamic, streams in */}
</Suspense>
<Suspense fallback={<ReviewSkeleton />}>
<Reviews /> {/* Independent suspense boundary */}
</Suspense>
</main>
);
}
Skeleton Components
function ProductSkeleton() {
return (
<div className="grid grid-cols-3 gap-4" aria-busy="true">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="animate-pulse">
<div className="bg-gray-200 rounded-lg h-48 w-full" />
<div className="bg-gray-200 rounded h-4 w-3/4 mt-4" />
<div className="bg-gray-200 rounded h-4 w-1/2 mt-2" />
</div>
))}
</div>
);
}
Step 5: Implement Route Prefetching
import Link from 'next/link';
// Next.js prefetches linked routes in viewport
export function Navigation() {
return (
<nav>
<Link href="/products" prefetch={true}>Products</Link>
<Link href="/about" prefetch={false}>About</Link>
</nav>
);
}
Step 6: Configure Caching Headers
// next.config.js
module.exports = {
async headers() {
return [
{
source: '/:path*',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=3600, stale-while-revalidate=86400',
},
],
},
];
},
};
Step 7: Performance Monitoring
// app/layout.js - Report Web Vitals
export function reportWebVitals(metric) {
fetch('/api/vitals', {
method: 'POST',
body: JSON.stringify(metric),
});
}
Results Summary
| Metric | Before | After |
|---|---|---|
| LCP | 4.2s | 1.8s |
| INP | 350ms | 120ms |
| CLS | 0.25 | 0.05 |
| Bundle Size | 380KB | 145KB |
| FCP | 2.8s | 0.9s |