Next.js Performance Optimization: From First Contentful Paint to Core Web Vitals
Performance is a feature. Users expect fast websites, and search engines reward fast sites with better rankings. In fact, studies show that a 1-second delay in page load can result in a 7% reduction in conversions.
Next.js provides excellent built-in optimizations, but many developers don't leverage them fully. In this comprehensive guide, I'll show you how to squeeze every millisecond of performance from your Next.js application.
Understanding Core Web Vitals
Google's Core Web Vitals are three key metrics that measure user experience:
Largest Contentful Paint (LCP)
The time it takes for the largest content element (text, image, video) to become visible.
Target: < 2.5 seconds
// ❌ Bad: Large images without optimization
export default function Hero() {
return <img src="/hero.jpg" width={1920} height={1080} />
}
// ✅ Good: Using Next.js Image component
import Image from "next/image"
export default function Hero() {
return (
<Image
src="/hero.jpg"
width={1920}
height={1080}
priority // Loads in high priority for LCP
placeholder="blur"
blurDataURL="data:image/..."
/>
)
}
First Input Delay (FID) / Interaction to Next Paint (INP)
The time before the browser can respond to user input. INP is the newer replacement for FID.
Target: < 100ms
// ❌ Bad: Heavy computation blocks main thread
export default function Filter({ items }) {
const [search, setSearch] = useState("")
// This runs on every keystroke!
const filtered = items.filter(item =>
JSON.stringify(item).includes(search)
)
return (
<>
<input onChange={(e) => setSearch(e.target.value)} />
{filtered.map(item => <ItemCard key={item.id} item={item} />)}
</>
)
}
// ✅ Good: Debounced search with Web Workers
import { useDeferredValue } from "react"
export default function Filter({ items }) {
const [search, setSearch] = useState("")
const deferredSearch = useDeferredValue(search)
const filtered = items.filter(item =>
item.name.toLowerCase().includes(deferredSearch.toLowerCase())
)
return (
<>
<input onChange={(e) => setSearch(e.target.value)} />
<Suspense fallback={<Skeleton />}>
{filtered.map(item => <ItemCard key={item.id} item={item} />)}
</Suspense>
</>
)
}
Cumulative Layout Shift (CLS)
Unexpected layout shifts that occur during page load.
Target: < 0.1
// ❌ Bad: Image without dimensions causes layout shift
<div className="grid grid-cols-3">
<img src="/product.jpg" /> {/* No width/height */}
</div>
// ✅ Good: Always specify dimensions
<div className="grid grid-cols-3">
<Image
src="/product.jpg"
width={300}
height={300}
placeholder="blur"
/>
</div>
// ✅ Also good: Use aspect ratio container
<div className="relative w-full aspect-square">
<Image src="/product.jpg" fill alt="Product" />
</div>
Image Optimization Mastery
Images are typically the largest assets. Next.js Image component handles this automatically.
Responsive Images
import Image from "next/image"
export default function ProductImage() {
return (
<Image
src="/product.jpg"
width={800}
height={600}
sizes="(max-width: 640px) 100vw,
(max-width: 1024px) 50vw,
33vw"
quality={90}
/>
)
}
The sizes prop is crucial—it tells Next.js which image sizes to generate:
- Mobile: 100% of viewport
- Tablet: 50% of viewport
- Desktop: 33% of viewport
Modern Formats
Next.js automatically generates WebP and AVIF formats:
// Next.js automatically:
// 1. Generates WebP (saves ~25% vs JPEG)
// 2. Generates AVIF (saves ~40% vs JPEG)
// 3. Selects best format based on browser support
// 4. Lazy loads below-the-fold images
// 5. Uses placeholder while loading
<Image src="/image.jpg" alt="Example" width={400} height={300} />
// Results in:
// <img srcset="
// /image-400w.avif 1x,
// /image-800w.avif 2x,
// /image-400w.webp 1x,
// /image-800w.webp 2x,
// /image-400w.jpg 1x,
// /image-800w.jpg 2x
// " />
Code Splitting and Lazy Loading
Dynamic Imports
// pages/dashboard.tsx
import dynamic from "next/dynamic"
import { Suspense } from "react"
const HeavyAnalytics = dynamic(
() => import("@/components/Analytics"),
{
loading: () => <div>Loading charts...</div>,
ssr: false // Don't render on server
}
)
export default function Dashboard() {
return (
<>
<Header />
<Suspense fallback={<Skeleton />}>
<HeavyAnalytics />
</Suspense>
</>
)
}
This splits the Analytics component into a separate chunk, loaded only when needed.
Route-based Code Splitting
Next.js automatically splits code per route. A user visiting /dashboard doesn't download code for /settings.
Initial page load: 45 KB
Route change to /dashboard: +12 KB
Route change to /settings: +8 KB
Versus bundling everything: 100 KB upfront.
Font Optimization
Google Fonts cause a major performance impact if not optimized.
// pages/_document.tsx
import { Html, Head, Main, NextScript } from "next/document"
export default function Document() {
return (
<Html lang="en">
<Head>
{/* ❌ Bad: External font URL blocks rendering */}
{/* <link href="https://fonts.googleapis.com/css?family=Poppins" /> */}
{/* ✅ Good: self-hosted with font preloading */}
<link
rel="preload"
href="/fonts/poppins-variable.woff2"
as="font"
type="font/woff2"
crossOrigin="anonymous"
/>
<style>
{`
@font-face {
font-family: 'Poppins';
src: url('/fonts/poppins-variable.woff2') format('woff2-variations');
font-weight: 100 900;
}
`}
</style>
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
)
}
Self-hosting fonts eliminates DNS lookups and can save 100-300ms.
Build-time Optimization
Static Generation (SSG)
// pages/blog/[slug].tsx
export async function getStaticProps({ params }) {
const post = await getPost(params.slug)
return {
props: { post },
revalidate: 3600 // Regenerate every hour
}
}
export async function getStaticPaths() {
const posts = await getAllPosts()
return {
paths: posts.map(post => ({
params: { slug: post.slug }
})),
fallback: "blocking"
}
}
export default function BlogPost({ post }) {
return <article>{post.content}</article>
}
SSG pages are pre-rendered at build time → instant delivery. Use ISR (Incremental Static Regeneration) to keep content fresh.
Incremental Static Regeneration (ISR)
export async function getStaticProps() {
return {
props: { data },
revalidate: 60 // Regenerate after 60 seconds
}
}
First request after 60 seconds triggers a background rebuild. Users get fresh content without waiting.
Monitoring Performance
Built-in Analytics
// pages/_app.tsx
import { useReportWebVitals } from "next/web-vitals"
export default function App({ Component, pageProps }) {
useReportWebVitals(metric => {
console.log(metric)
// Send to analytics
fetch("/api/metrics", {
method: "POST",
body: JSON.stringify(metric)
})
})
return <Component {...pageProps} />
}
Performance API
export function measurePerformance() {
const metrics = performance.getEntriesByType("navigation")[0]
console.log({
DNS: metrics.domainLookupEnd - metrics.domainLookupStart,
TCP: metrics.connectEnd - metrics.connectStart,
TTFB: metrics.responseStart - metrics.requestStart,
Download: metrics.responseEnd - metrics.responseStart,
DOMParse: metrics.domInteractive - metrics.domLoading,
Resources: metrics.loadEventStart - metrics.domInteractive,
Total: metrics.loadEventEnd - metrics.fetchStart
})
}
Real-World Results
I applied these optimizations to a client's e-commerce site:
| Metric | Before | After | Improvement |
|---|---|---|---|
| LCP | 3.2s | 1.8s | 44% faster |
| FID | 145ms | 52ms | 64% faster |
| CLS | 0.15 | 0.05 | 67% better |
| Total Bundle | 250KB | 89KB | 64% smaller |
Google Lighthouse score improved from 62 to 94.
Performance Checklist
- Use Next.js Image component for all images
- Set image
sizesprop for responsive behavior - Add
priorityprop to LCP images - Implement dynamic imports for heavy components
- Self-host Google Fonts
- Use
next/scriptfor third-party scripts - Implement ISR for content-heavy pages
- Monitor Core Web Vitals with analytics
- Test on real 4G networks (DevTools)
- Use Lighthouse CI in CI/CD pipeline
Conclusion
Next.js provides all the tools needed to build blazingly fast applications. The key is understanding why each optimization matters and measuring the impact.
Start with the biggest wins: image optimization and code splitting. Then progressively apply other techniques as needed.
Performance is not a one-time task—it's an ongoing commitment. Monitor, measure, and iterate continuously.
What optimization technique has helped your Next.js app the most? Share in the comments below!

