All articles

Next.js Performance Optimization: Complete Guide

Performance is crucial for user experience and SEO. Next.js provides powerful built-in optimizations, but knowing how to leverage them effectively can make the difference between a good and exceptional application.

Core Web Vitals and Next.js

Next.js is designed to help you achieve excellent Core Web Vitals scores:

  • Largest Contentful Paint (LCP) - Loading performance
  • First Input Delay (FID) - Interactivity
  • Cumulative Layout Shift (CLS) - Visual stability
// Monitor Core Web Vitals
export function reportWebVitals(metric) {
  console.log(metric)

  // Send to analytics
  if (metric.label === 'web-vital') {
    window.gtag('event', metric.name, {
      value: Math.round(metric.name === 'CLS' ? metric.value * 1000 : metric.value),
      event_label: metric.id,
    })
  }
}

Image Optimization

Next.js Image component provides automatic optimization:

import Image from 'next/image'

function OptimizedImageExample() {
  return (
    <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
      {/* Basic optimized image */}
      <Image
        src="/hero-image.jpg"
        alt="Hero image"
        width={800}
        height={600}
        priority // Load immediately for above-the-fold images
        className="rounded-lg"
      />

      {/* Responsive images */}
      <Image
        src="/responsive-image.jpg"
        alt="Responsive image"
        fill
        sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
        className="object-cover rounded-lg"
      />

      {/* Lazy loaded images */}
      <Image
        src="/lazy-image.jpg"
        alt="Lazy loaded image"
        width={400}
        height={300}
        loading="lazy" // Default behavior
        placeholder="blur"
        blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAhEAACAQMDBQAAAAAAAAAAAAABAgMABAUGIWGRkqGx0f/EABUBAQEAAAAAAAAAAAAAAAAAAAMF/8QAGhEAAgIDAAAAAAAAAAAAAAAAAAECEgMRkf/aAAwDAQACEQMRAD8AltJagyeH0AthI5xdrLcNM91BF5pX2HaH9bcfaSXWGaRmknyJckliyjqTzSlT54b6bk+h0R4qZRMmZZ5bH8ASz6Uj9k1z1+tZO8/fSUmXX83cjd3cQs3cXy/XvPt6/ACbavImmWCz3n1WlW6S4VKzBPqZhWNOgzCNKOEbKtOhRo9gH/o1JCSj/9k="
      />
    </div>
  )
}

// Configure images in next.config.js
module.exports = {
  images: {
    domains: ['example.com', 'cdn.example.com'],
    deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
    imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
    formats: ['image/webp'],
  },
}

Code Splitting and Dynamic Imports

Implement strategic code splitting to reduce initial bundle size:

import dynamic from 'next/dynamic'
import { Suspense } from 'react'

// Dynamic import with loading state
const HeavyComponent = dynamic(() => import('../components/HeavyComponent'), {
  loading: () => <div className="animate-pulse bg-gray-200 h-32 rounded"></div>,
  ssr: false, // Disable server-side rendering if needed
})

// Import only when needed
const ChartComponent = dynamic(() => import('recharts').then(mod => ({
  default: mod.BarChart
})), {
  loading: () => <div>Loading chart...</div>
})

// Conditional loading
function Dashboard({ showAdvanced }) {
  const AdvancedAnalytics = dynamic(
    () => import('../components/AdvancedAnalytics'),
    { ssr: false }
  )

  return (
    <div>
      <h1>Dashboard</h1>
      <BasicStats />

      {showAdvanced && (
        <Suspense fallback={<div>Loading advanced analytics...</div>}>
          <AdvancedAnalytics />
        </Suspense>
      )}
    </div>
  )
}

// Route-level code splitting
export default function Page() {
  return (
    <div>
      <HeavyComponent />
      <ChartComponent data={chartData} />
    </div>
  )
}

Font Optimization

Optimize fonts with Next.js built-in font optimization:

import { Inter, Roboto_Mono } from 'next/font/google'
import localFont from 'next/font/local'

// Google Fonts optimization
const inter = Inter({
  subsets: ['latin'],
  display: 'swap',
  variable: '--font-inter',
})

const robotoMono = Roboto_Mono({
  subsets: ['latin'],
  display: 'swap',
  variable: '--font-roboto-mono',
})

// Local fonts
const customFont = localFont({
  src: [
    {
      path: './fonts/CustomFont-Regular.woff2',
      weight: '400',
      style: 'normal',
    },
    {
      path: './fonts/CustomFont-Bold.woff2',
      weight: '700',
      style: 'normal',
    },
  ],
  variable: '--font-custom',
  display: 'swap',
})

// Root layout
export default function RootLayout({ children }) {
  return (
    <html lang="en" className={`${inter.variable} ${robotoMono.variable} ${customFont.variable}`}>
      <body className="font-sans">
        {children}
      </body>
    </html>
  )
}

// Tailwind config for custom fonts
module.exports = {
  theme: {
    extend: {
      fontFamily: {
        sans: ['var(--font-inter)'],
        mono: ['var(--font-roboto-mono)'],
        custom: ['var(--font-custom)'],
      },
    },
  },
}

Caching Strategies

Implement effective caching at multiple levels:

// 1. Static Generation with ISR
export async function generateStaticParams() {
  const posts = await fetch('https://api.example.com/posts').then(res => res.json())
  return posts.map(post => ({ slug: post.slug }))
}

export default async function BlogPost({ params }) {
  const post = await fetch(`https://api.example.com/posts/${params.slug}`, {
    next: { revalidate: 3600 } // Revalidate every hour
  }).then(res => res.json())

  return (
    <article>
      <h1>{post.title}</h1>
      <div>{post.content}</div>
    </article>
  )
}

// 2. API Route caching
export async function GET(request) {
  const data = await expensiveDataFetch()

  return Response.json(data, {
    headers: {
      'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
    },
  })
}

// 3. React Cache for deduplication
import { cache } from 'react'

const getUser = cache(async (id) => {
  const response = await fetch(`/api/users/${id}`)
  return response.json()
})

// Multiple calls to getUser(1) will only result in one fetch
function UserProfile({ userId }) {
  const user = await getUser(userId)
  return <div>{user.name}</div>
}

function UserPosts({ userId }) {
  const user = await getUser(userId) // Cached result
  return <div>Posts by {user.name}</div>
}

Bundle Analysis and Optimization

Analyze and optimize your bundles:

// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
  enabled: process.env.ANALYZE === 'true',
})

module.exports = withBundleAnalyzer({
  experimental: {
    optimizeCss: true,
  },
  webpack: (config, { dev, isServer }) => {
    if (!dev && !isServer) {
      config.resolve.alias = {
        ...config.resolve.alias,
        // Replace heavy libraries with lighter alternatives
        'lodash': 'lodash-es',
      }
    }
    return config
  },
})

// Package.json script
{
  "scripts": {
    "analyze": "ANALYZE=true npm run build"
  }
}

Database and API Optimization

Optimize data fetching and database queries:

// 1. Parallel data fetching
async function ProductPage({ id }) {
  const [product, reviews, recommendations] = await Promise.all([
    getProduct(id),
    getReviews(id),
    getRecommendations(id),
  ])

  return (
    <div>
      <ProductDetails product={product} />
      <Reviews reviews={reviews} />
      <Recommendations products={recommendations} />
    </div>
  )
}

// 2. Database query optimization
async function getProductsWithReviews() {
  // Single query with join instead of N+1 queries
  return await db.product.findMany({
    include: {
      reviews: {
        take: 5,
        orderBy: { createdAt: 'desc' },
      },
      _count: {
        select: { reviews: true },
      },
    },
  })
}

// 3. Request deduplication
import { unstable_cache } from 'next/cache'

const getCachedUser = unstable_cache(
  async (userId) => {
    return await db.user.findUnique({ where: { id: userId } })
  },
  ['user'],
  { revalidate: 3600 }
)

Streaming and Suspense

Improve perceived performance with streaming:

import { Suspense } from 'react'

function LoadingSkeleton() {
  return (
    <div className="animate-pulse">
      <div className="h-4 bg-gray-200 rounded w-3/4 mb-2"></div>
      <div className="h-4 bg-gray-200 rounded w-1/2 mb-2"></div>
      <div className="h-32 bg-gray-200 rounded"></div>
    </div>
  )
}

export default function Page() {
  return (
    <div>
      <h1>Dashboard</h1>

      {/* Fast components render immediately */}
      <QuickStats />

      {/* Slow components stream in when ready */}
      <Suspense fallback={<LoadingSkeleton />}>
        <SlowAnalytics />
      </Suspense>

      <Suspense fallback={<LoadingSkeleton />}>
        <SlowReports />
      </Suspense>
    </div>
  )
}

async function SlowAnalytics() {
  await new Promise(resolve => setTimeout(resolve, 2000)) // Simulate slow query
  const data = await getAnalyticsData()

  return <AnalyticsChart data={data} />
}

Performance Monitoring

Monitor performance in production:

// app/layout.tsx
import { SpeedInsights } from '@vercel/speed-insights/next'
import { Analytics } from '@vercel/analytics/react'

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        {children}
        <SpeedInsights />
        <Analytics />
      </body>
    </html>
  )
}

// Custom performance monitoring
function usePerformanceMonitoring() {
  useEffect(() => {
    // Monitor route changes
    const handleRouteChange = (url) => {
      // Track page load time
      const loadTime = performance.now()
      analytics.track('Page Load', { url, loadTime })
    }

    // Monitor Core Web Vitals
    const observer = new PerformanceObserver((list) => {
      list.getEntries().forEach((entry) => {
        if (entry.entryType === 'navigation') {
          console.log('Navigation timing:', entry)
        }
      })
    })

    observer.observe({ entryTypes: ['navigation', 'paint'] })

    return () => observer.disconnect()
  }, [])
}

Advanced Optimization Techniques

Prefetching

import Link from 'next/link'
import { useRouter } from 'next/navigation'

function OptimizedNavigation() {
  const router = useRouter()

  return (
    <nav>
      {/* Automatic prefetching with Link */}
      <Link href="/dashboard" prefetch={true}>
        Dashboard
      </Link>

      {/* Programmatic prefetching */}
      <button
        onMouseEnter={() => router.prefetch('/settings')}
        onClick={() => router.push('/settings')}
      >
        Settings
      </button>
    </nav>
  )
}

Service Worker for Caching

// public/sw.js
const CACHE_NAME = 'my-app-v1'
const urlsToCache = [
  '/',
  '/static/js/bundle.js',
  '/static/css/main.css',
]

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then((cache) => cache.addAll(urlsToCache))
  )
})

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request)
      .then((response) => {
        return response || fetch(event.request)
      })
  )
})

Performance Checklist

Images: Use Next.js Image component with proper sizing ✅ Fonts: Optimize with next/font ✅ Code Splitting: Dynamic imports for large components ✅ Caching: Implement appropriate cache strategies ✅ Bundle Size: Analyze and optimize bundle size ✅ Database: Optimize queries and use connection pooling ✅ Streaming: Use Suspense for better perceived performance ✅ Monitoring: Track Core Web Vitals and performance metrics ✅ Prefetching: Preload critical resources ✅ Compression: Enable gzip/brotli compression

By implementing these optimization techniques, you'll create Next.js applications that load faster, provide better user experiences, and rank higher in search engines. Remember to measure performance before and after optimizations to ensure they're providing the expected benefits.

Next.js Performance Optimization: Complete Guide