Why Next.js Performance Needs Deliberate Attention
Next.js gives you a performance foundation out of the box — automatic code splitting, server rendering, image optimization. But "good by default" doesn't mean "optimized for your app."
Real-world Next.js performance issues are often self-inflicted: images loaded without the <Image> component, fonts causing layout shifts, oversized client bundles from poor import patterns, and caching strategies left at defaults.
This guide covers the practical optimizations that make meaningful differences in Core Web Vitals scores.
Image Optimization
Images are typically the largest Contentful Paint element on a page. Next.js's <Image> component handles the hard parts automatically:
import Image from 'next/image'
// Basic usage — Next.js handles format conversion, sizing, lazy loading
<Image
src="/hero.jpg"
alt="Hero image"
width={1200}
height={630}
priority // Above-the-fold images should use priority (disables lazy loading)
/>
// Remote images — add domain to next.config.js
<Image
src="https://cdn.example.com/avatar.jpg"
alt="User avatar"
width={64}
height={64}
className="rounded-full"
/>
// Fill mode — fills the parent container
<div style={{ position: 'relative', height: '400px' }}>
<Image
src="/background.jpg"
alt="Background"
fill
style={{ objectFit: 'cover' }}
sizes="100vw" // Critical for fill images
/>
</div>
The sizes Attribute
This is the most commonly missed optimization. Without sizes, Next.js doesn't know how large the image will be rendered:
// ❌ Without sizes — Next.js serves full-size image regardless of viewport
<Image src="/card.jpg" alt="Card" width={400} height={300} />
// ✅ With sizes — serves appropriately-sized image
<Image
src="/card.jpg"
alt="Card"
width={400}
height={300}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 400px"
// Translates to: "Full width on mobile, half on tablet, 400px on desktop"
/>
Configuring Image Domains
// next.config.js
module.exports = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'cdn.example.com',
pathname: '/images/**',
},
{
protocol: 'https',
hostname: '**.amazonaws.com', // Wildcard subdomain
},
],
// Custom optimization
formats: ['image/avif', 'image/webp'], // Serve AVIF when supported
deviceSizes: [640, 828, 1080, 1200, 1920], // Breakpoints for srcset
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384], // For fixed-size images
},
}
Placeholder and Blur
import Image from 'next/image'
// Static import — blur data URL generated automatically
import heroImage from '/public/hero.jpg'
<Image
src={heroImage}
alt="Hero"
placeholder="blur" // Shows blur while loading
priority
/>
// Dynamic images — generate your own blur placeholder
<Image
src={post.coverImage}
alt={post.title}
width={800}
height={400}
placeholder="blur"
blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQ..." // 10px blurred
/>
Font Optimization
Fonts cause Cumulative Layout Shift (CLS) and block rendering. Next.js's next/font eliminates both issues:
// app/layout.tsx
import { Inter, Fira_Code } from 'next/font/google'
const inter = Inter({
subsets: ['latin'],
display: 'swap', // Prevents invisible text while font loads
variable: '--font-inter', // CSS variable for Tailwind
})
const firaCode = Fira_Code({
subsets: ['latin'],
weight: ['400', '500'],
variable: '--font-fira-code',
})
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={`${inter.variable} ${firaCode.variable}`}>
<body className={inter.className}>
{children}
</body>
</html>
)
}
What next/font does behind the scenes:
- Downloads fonts at build time (no runtime requests)
- Self-hosts them with optimal cache headers
- Inlines
@font-facedeclarations withsize-adjustto prevent CLS - Generates CSS variables for use in Tailwind
Local Fonts
import localFont from 'next/font/local'
const brandFont = localFont({
src: [
{
path: '../fonts/BrandFont-Regular.woff2',
weight: '400',
style: 'normal',
},
{
path: '../fonts/BrandFont-Bold.woff2',
weight: '700',
style: 'normal',
},
],
variable: '--font-brand',
display: 'swap',
})
Bundle Analysis
Before optimizing, measure. Next.js has a built-in bundle analyzer:
npm install @next/bundle-analyzer
# next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
})
module.exports = withBundleAnalyzer({
// your config
})
# Run analysis
ANALYZE=true npm run build
Common Bundle Issues
1. Importing entire libraries when you need one function:
// ❌ Imports all of lodash (~70KB gzipped)
import _ from 'lodash'
const result = _.groupBy(data, 'category')
// ✅ Import only what you need (~1KB)
import groupBy from 'lodash/groupBy'
const result = groupBy(data, 'category')
// ✅ Or use native alternatives
const result = data.reduce((acc, item) => {
(acc[item.category] ??= []).push(item)
return acc
}, {} as Record<string, typeof data>)
2. Date libraries:
// ❌ moment.js = 67KB gzipped, includes all locales
import moment from 'moment'
// ✅ date-fns = tree-shakeable
import { format, parseISO } from 'date-fns'
// Or use Intl API for basic formatting (zero bytes!)
new Intl.DateTimeFormat('en-US', { dateStyle: 'long' }).format(date)
3. Icon libraries:
// ❌ Imports entire icon set
import { IconSettings, IconUser } from '@tabler/icons-react'
// ✅ Same — modern packages are tree-shakeable
// But verify with bundle analyzer that tree-shaking is working
Code Splitting Strategies
Next.js automatically code-splits by route. But large components within a route still block rendering:
import dynamic from 'next/dynamic'
// Lazy-load heavy components
const MarkdownEditor = dynamic(() => import('./MarkdownEditor'), {
loading: () => <p>Loading editor...</p>,
ssr: false, // Don't SSR if it's client-only (e.g., uses DOM APIs)
})
// Conditional loading
const AdminPanel = dynamic(() => import('./AdminPanel'))
function Page({ user }: { user: User }) {
return (
<div>
<UserProfile user={user} />
{user.isAdmin && <AdminPanel />} {/* Only loaded for admins */}
</div>
)
}
Preloading
// Preload a component before it's needed
const AdminPanel = dynamic(() => import('./AdminPanel'))
function Page({ user }: { user: User }) {
// Preload on hover — common UX pattern
function handleMouseEnter() {
AdminPanel.preload?.()
}
return (
<button onMouseEnter={handleMouseEnter} onClick={openAdmin}>
Admin Panel
</button>
)
}
Caching Strategy
Next.js 14+ has a layered caching system:
// 1. Static rendering (build time) — fastest
// page.tsx with no dynamic data = statically rendered
export default async function StaticPage() {
const data = await fetch('https://api.example.com/static-data', {
cache: 'force-cache' // Cached indefinitely
})
return <Component data={data} />
}
// 2. ISR — revalidate on interval
export const revalidate = 3600 // Revalidate every hour
export default async function IsrPage() {
const posts = await fetch('/api/posts', {
next: { revalidate: 3600 }
}).then(r => r.json())
return <PostList posts={posts} />
}
// 3. On-demand revalidation
import { revalidatePath, revalidateTag } from 'next/cache'
export async function POST(request: Request) {
// Called by a webhook when content changes
await revalidatePath('/blog') // Revalidate specific path
await revalidateTag('posts') // Revalidate all fetches with this tag
return Response.json({ revalidated: true })
}
// 4. Dynamic (no cache) — always fresh
export const dynamic = 'force-dynamic'
// Or:
const data = await fetch(url, { cache: 'no-store' })
Measuring Core Web Vitals
// app/layout.tsx — Web Vitals reporting
'use client'
import { useReportWebVitals } from 'next/web-vitals'
export function WebVitals() {
useReportWebVitals((metric) => {
console.log(metric) // { id, name, value, rating }
// Send to analytics
if (metric.rating === 'poor') {
analytics.track('poor_web_vital', {
metric: metric.name,
value: metric.value,
page: window.location.pathname,
})
}
})
return null
}
Key Metrics to Watch
| Metric | Good | Needs Improvement | Poor |
|---|---|---|---|
| LCP (Largest Contentful Paint) | < 2.5s | 2.5–4.0s | > 4.0s |
| INP (Interaction to Next Paint) | < 200ms | 200–500ms | > 500ms |
| CLS (Cumulative Layout Shift) | < 0.1 | 0.1–0.25 | > 0.25 |
| FCP (First Contentful Paint) | < 1.8s | 1.8–3.0s | > 3.0s |
| TTFB (Time to First Byte) | < 800ms | 800ms–1.8s | > 1.8s |
Practical Optimization Checklist
Images:
□ All images use <Image> component
□ Above-fold images have priority prop
□ Responsive images have correct sizes prop
□ placeholder="blur" for important images
Fonts:
□ Using next/font instead of <link> or @import
□ Only loading required subsets and weights
□ Using font-display: swap
Bundle:
□ Ran bundle analyzer, identified largest modules
□ Heavy components use dynamic() import
□ No full library imports (lodash, moment, etc.)
□ Third-party scripts use next/script with strategy
Caching:
□ Static pages use force-cache
□ Dynamic pages specify cache strategy explicitly
□ ISR configured for content that changes occasionally
□ On-demand revalidation for CMS-driven content
Performance:
□ Third-party scripts use next/script
□ Fonts preloaded in <head>
□ Largest Contentful Paint element identified and optimized
□ CLS score below 0.1 (no layout shifts from fonts/images)
Third-Party Script Optimization
import Script from 'next/script'
// Strategy options:
// - beforeInteractive: Loads before page hydration (blocking — avoid if possible)
// - afterInteractive: Loads after hydration (default — good for analytics)
// - lazyOnload: Loads during idle time (good for non-critical scripts)
// - worker: Loads in Web Worker (experimental — moves JS off main thread)
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
{children}
{/* Analytics — load after page is interactive */}
<Script
src="https://www.googletagmanager.com/gtag/js?id=GA_ID"
strategy="afterInteractive"
/>
{/* Chat widget — load during idle time */}
<Script
src="https://chat.example.com/widget.js"
strategy="lazyOnload"
onLoad={() => console.log('Chat loaded')}
/>
</body>
</html>
)
}
Next.js performance optimization is about understanding the knobs available and applying them deliberately. The biggest wins usually come from image optimization (using <Image> with proper sizes), eliminating layout shift from fonts (switching to next/font), and identifying and splitting large client bundles. Measure first, optimize second.