Next.js Performance Optimization Guide | Core Web Vitals
이 글의 핵심
Slow Next.js apps lose users and rank lower in search. This guide covers the most impactful performance optimizations — from bundle size reduction to image optimization and caching strategies — with real measurement techniques.
Why Next.js Performance Matters
A 100ms increase in load time reduces conversion by 1%. Core Web Vitals directly affect Google rankings. And with Next.js’s rich feature set, it’s easy to accidentally ship a slow app.
This guide focuses on measurement first, optimization second — never guess.
1. Measure First
# Build and analyze
npm run build
# Check what's included in your bundle
ANALYZE=true npm run build
# (requires @next/bundle-analyzer)
# Real-user metrics
# Next.js Speed Insights in vercel.com dashboard
# Or: Google Search Console → Core Web Vitals
Install 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
})
ANALYZE=true npm run build
# Opens treemap of bundle contents in browser
2. Image Optimization
Images are the #1 LCP killer. Always use next/image:
import Image from 'next/image'
// ❌ Bad: regular img tag
<img src="/hero.jpg" alt="Hero" style={{ width: '100%' }} />
// ✅ Good: next/image with correct props
<Image
src="/hero.jpg"
alt="Hero image"
width={1200}
height={600}
priority // LCP image: load immediately, don't lazy-load
quality={85} // 75-85 is the sweet spot
placeholder="blur" // show blurred placeholder while loading
blurDataURL="data:image/jpeg;base64,..." // tiny base64 preview
/>
// Below-the-fold images: lazy load (default)
<Image
src="/product.jpg"
alt="Product"
width={400}
height={400}
// priority NOT set → lazy loaded automatically
/>
Remote images
// next.config.js
module.exports = {
images: {
remotePatterns: [
{ protocol: 'https', hostname: 'images.unsplash.com' },
{ protocol: 'https', hostname: 'cdn.yoursite.com' },
],
formats: ['image/avif', 'image/webp'], // serve AVIF first, WebP fallback
},
}
3. Bundle Size Reduction
Dynamic imports (code splitting)
import dynamic from 'next/dynamic'
// Heavy component loaded only when needed
const HeavyChart = dynamic(() => import('./HeavyChart'), {
loading: () => <div>Loading chart...</div>,
ssr: false, // client-only (e.g., uses window)
})
// Heavy library (e.g., markdown editor, date picker)
const MarkdownEditor = dynamic(() => import('react-md-editor'), { ssr: false })
// Only load when user interacts
const [showModal, setShowModal] = useState(false)
const Modal = dynamic(() => import('./HeavyModal'))
return (
<>
<button onClick={() => setShowModal(true)}>Open Modal</button>
{showModal && <Modal onClose={() => setShowModal(false)} />}
</>
)
Replace heavy dependencies
# Before replacing, check your bundle
ANALYZE=true npm run build
# Common replacements
lodash → lodash-es (tree-shakeable) or individual functions
moment → date-fns or dayjs (~80% smaller)
axios → native fetch (zero bytes)
react-icons → @phosphor-icons/react (tree-shakeable)
// ❌ Imports entire lodash
import _ from 'lodash'
const sorted = _.sortBy(users, 'name')
// ✅ Tree-shakeable import
import { sortBy } from 'lodash-es'
const sorted = sortBy(users, 'name')
Analyze and remove unused code
# Find unused exports
npx knip
# Check package sizes before installing
npx bundlephobia <package-name>
4. Caching Strategies (App Router)
// Static (cached forever, revalidated on deploy)
export const dynamic = 'force-static'
// Static with time-based revalidation (ISR)
export const revalidate = 3600 // revalidate every hour
// Dynamic (no cache, runs on every request)
export const dynamic = 'force-dynamic'
// Per-fetch caching
const data = await fetch('/api/posts', {
next: { revalidate: 300 } // cache for 5 minutes
})
// No cache for this specific fetch
const user = await fetch('/api/user', {
cache: 'no-store'
})
// Revalidate on demand (after form submit, admin action)
import { revalidatePath, revalidateTag } from 'next/cache'
async function publishPost(postId: string) {
await db.posts.update({ id: postId, published: true })
revalidatePath('/blog') // revalidate the blog listing
revalidatePath(`/blog/${postId}`) // revalidate this specific post
}
5. Reducing LCP (Largest Contentful Paint)
LCP measures when the largest element is visible. Target: < 2.5s.
// ✅ Preload LCP image
import { headers } from 'next/headers'
// In layout.tsx or page.tsx
export default function Layout({ children }) {
return (
<html>
<head>
<link
rel="preload"
as="image"
href="/hero.jpg"
// For responsive images:
imageSrcSet="/hero-800.jpg 800w, /hero-1200.jpg 1200w"
/>
</head>
<body>{children}</body>
</html>
)
}
// ✅ Priority on LCP image
<Image src="/hero.jpg" priority alt="Hero" width={1200} height={600} />
// ✅ Preconnect to external image domains
<link rel="preconnect" href="https://images.unsplash.com" />
6. Preventing CLS (Cumulative Layout Shift)
CLS measures visual stability. Target: < 0.1.
// ❌ Image without dimensions causes CLS
<img src="/logo.png" alt="Logo" />
// ✅ Always specify dimensions
<Image src="/logo.png" alt="Logo" width={120} height={40} />
// ❌ Dynamic content loading without placeholder
{user ? <UserAvatar user={user} /> : null}
// ✅ Reserve space while loading
<div style={{ width: 40, height: 40 }}>
{user ? <UserAvatar user={user} /> : <Skeleton variant="circular" />}
</div>
// ❌ Web font without fallback (text shifts when font loads)
// ✅ Use next/font (automatic font optimization)
import { Inter } from 'next/font/google'
const inter = Inter({
subsets: ['latin'],
display: 'swap',
preload: true,
})
7. Reducing INP (Interaction to Next Paint)
INP measures responsiveness to clicks and inputs. Target: < 200ms.
// ❌ Heavy synchronous computation blocks the main thread
function handleSearch(query: string) {
const results = heavySearch(allProducts, query) // blocks UI
setResults(results)
}
// ✅ Defer non-critical work
import { startTransition } from 'react'
function handleSearch(query: string) {
setQuery(query) // urgent: update input immediately
startTransition(() => {
setResults(heavySearch(allProducts, query)) // defer: can be interrupted
})
}
// ✅ Use useDeferredValue for expensive renders
const deferredQuery = useDeferredValue(query)
const results = useMemo(() => search(deferredQuery), [deferredQuery])
// ✅ Move heavy work to web workers
const worker = new Worker(new URL('./search.worker.ts', import.meta.url))
worker.postMessage({ query })
worker.onmessage = (e) => setResults(e.data)
8. Server Components vs Client Components
Getting this boundary right is crucial for performance:
// ✅ Server Component (default in App Router)
// - Zero JS sent to client
// - Can directly access DB/API
// - Can't use useState, useEffect, event handlers
async function PostList() {
const posts = await db.posts.findMany() // direct DB access
return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>
}
// ✅ Client Component (add 'use client' only when needed)
// - Has interactivity (useState, useEffect, events)
// - JS is sent to client
'use client'
function SearchInput({ onSearch }) {
const [query, setQuery] = useState('')
return <input value={query} onChange={e => { setQuery(e.target.value); onSearch(e.target.value) }} />
}
// ✅ Optimal: Server Component wraps Client Component
async function SearchPage() {
const initialPosts = await db.posts.findMany() // server data
return (
<div>
<SearchInput onSearch={handleSearch} /> // client interaction
<PostList posts={initialPosts} /> // server data, no JS
</div>
)
}
9. Next.js Config Optimizations
// next.config.js
module.exports = {
// Compress responses
compress: true,
// Strict mode catches issues early
reactStrictMode: true,
// Experimental optimizations
experimental: {
optimizePackageImports: ['@mui/material', 'lucide-react', 'date-fns'],
// ↑ Tree-shake these packages automatically
},
// Headers for caching static assets
async headers() {
return [
{
source: '/static/:path*',
headers: [{ key: 'Cache-Control', value: 'public, max-age=31536000, immutable' }],
},
]
},
}
Performance Checklist
□ Run Lighthouse audit (target: 90+ on all scores)
□ Check Core Web Vitals in Google Search Console
□ Use next/image for ALL images — no <img> tags
□ Set priority on the LCP image
□ Dynamic import for components > 50KB (charts, editors, maps)
□ Add revalidate to data fetches (avoid force-dynamic where possible)
□ Use next/font — eliminate font-related CLS
□ Check bundle analyzer — nothing over 100KB that you don't expect
□ All images have explicit width/height — no CLS
□ Use startTransition for non-urgent state updates
□ Run 'npx knip' — remove unused code
Key Takeaways
| Metric | Key fix |
|---|---|
| LCP | next/image with priority, preconnect |
| CLS | Image dimensions, next/font, skeleton placeholders |
| INP | startTransition, defer heavy computation |
| Bundle size | Dynamic imports, replace heavy libraries |
| Time to First Byte | Static/ISR instead of SSR, CDN caching |
Measure with Lighthouse and Google Search Console, fix the highest-impact items first, and re-measure. The biggest wins — images, bundle size, correct rendering mode — are often 5-line changes that cut load time in half.
Frequently Asked Questions (FAQ)
Q. When would I use this in practice?
A. Optimize Next.js apps for speed. Covers Core Web Vitals, Image optimization, bundle analysis, lazy loading, caching stra…
Q. What should I read before this?
A. Follow the previous article or related articles links at the bottom of each post to learn in sequence.
Q. Where can I study this more deeply?
A. Check cppreference and the relevant library’s official documentation. The reference links at the end of the article are also worth using.
Related Articles (Internal Links)
Other articles related to this topic.
Keywords Covered in This Article (Related Search Terms)
This article covers Next.js, Performance, Core Web Vitals, React, SEO, Web.