正在加载,请稍候…

Next.js Performance Optimization: Images, Fonts, Caching, and Bundle Analysis

Practical Next.js performance optimization guide: Image component, font optimization, code splitting, bundle analysis, caching strategies, and measuring Core Web Vitals improvements.

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:

  1. Downloads fonts at build time (no runtime requests)
  2. Self-hosts them with optimal cache headers
  3. Inlines @font-face declarations with size-adjust to prevent CLS
  4. 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.