Next.js Performance Optimization Guide | Core Web Vitals, Images, Bundle Size
이 글의 핵심
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.