Next.js Performance Optimization Guide | Core Web Vitals, Images, Bundle Size

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

MetricKey fix
LCPnext/image with priority, preconnect
CLSImage dimensions, next/font, skeleton placeholders
INPstartTransition, defer heavy computation
Bundle sizeDynamic imports, replace heavy libraries
Time to First ByteStatic/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.