Optimizing Next.js Performance: Comprehensive Guide 2025

11 min readCong Dinh
Optimizing Next.js Performance: Comprehensive Guide 2025

Optimizing Next.js Performance: Comprehensive Guide 2025

Performance isn't just about speed - it's about user experience, SEO ranking, and conversion rates. In this article, we'll explore techniques to optimize your Next.js application to achieve a Lighthouse score above 95 and improve Core Web Vitals.

Core Web Vitals Overview

Core Web Vitals are a set of important metrics defined by Google to measure user experience. Google uses these metrics as ranking factors for SEO.

1. Largest Contentful Paint (LCP)

LCP measures the time it takes for the largest content element to display in the viewport.

Target: < 2.5 seconds

Meaning: How quickly users see the main content

LCP typically includes:

  • Images
  • Video thumbnails
  • Background images with CSS
  • Block-level text elements
// Improve LCP with Next.js Image
import Image from 'next/image';
 
export function Hero() {
  return (
    <div className="relative h-screen">
      {/* ✅ priority flag to preload hero image */}
      <Image
        src="/hero-background.jpg"
        alt="Hero Background"
        fill
        priority
        sizes="100vw"
        className="object-cover"
      />
      <h1 className="relative z-10">Welcome to our site</h1>
    </div>
  );
}

2. First Input Delay (FID) / Interaction to Next Paint (INP)

FID (being replaced by INP) measures the time from when a user interacts to when the browser responds.

Target FID: < 100ms
Target INP: < 200ms

Meaning: How responsive the website is to user interactions

How to improve:

  • Minimize JavaScript execution time
  • Code splitting to reduce main thread blocking
  • Use Web Workers for heavy computations
// ❌ Bad: Heavy computation blocking main thread
function ExpensiveComponent({ data }: { data: number[] }) {
  const result = data.reduce((acc, val) => acc + Math.pow(val, 2), 0);
  return <div>Result: {result}</div>;
}
 
// ✅ Good: Memoize expensive calculations
import { useMemo } from 'react';
 
function ExpensiveComponent({ data }: { data: number[] }) {
  const result = useMemo(
    () => data.reduce((acc, val) => acc + Math.pow(val, 2), 0),
    [data]
  );
  return <div>Result: {result}</div>;
}

3. Cumulative Layout Shift (CLS)

CLS measures visual stability - how many unexpected layout shifts occur.

Target: < 0.1

Meaning: Does content jump around while loading

Common causes:

  • Images without dimensions
  • Ads, embeds, iframes without reserved space
  • Web fonts causing FOIT/FOUT
  • Dynamic content insertion
// ❌ Bad: No specified dimensions
<img src="/photo.jpg" alt="Photo" />
 
// ✅ Good: Always specify width and height
import Image from 'next/image';
 
<Image
  src="/photo.jpg"
  alt="Photo"
  width={800}
  height={600}
  // or use fill with aspect-ratio container
/>

Image Optimization

Images typically account for 50-70% of page weight. Next.js Image component provides automatic optimization.

Next.js Image Component

import Image from 'next/image';
 
// ✅ Basic usage with automatic optimization
export function ProductImage() {
  return (
    <Image
      src="/products/laptop.jpg"
      alt="Gaming Laptop"
      width={800}
      height={600}
      quality={85} // Default: 75
    />
  );
}
 
// ✅ Responsive images with sizes
export function ResponsiveImage() {
  return (
    <Image
      src="/banner.jpg"
      alt="Banner"
      width={1200}
      height={400}
      sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
    />
  );
}

Lazy Loading Images

Next.js lazy loads images by default when they're outside the viewport:

// ✅ Lazy load by default (except priority images)
export function Gallery({ images }: { images: string[] }) {
  return (
    <div className="grid grid-cols-3 gap-4">
      {images.map((src, index) => (
        <Image
          key={src}
          src={src}
          alt={`Gallery image ${index + 1}`}
          width={400}
          height={300}
          loading="lazy" // Explicit (but default already)
        />
      ))}
    </div>
  );
}
 
// Above the fold images should use priority
export function HeroSection() {
  return (
    <Image
      src="/hero.jpg"
      alt="Hero"
      width={1920}
      height={1080}
      priority // ✅ Preload hero image
    />
  );
}

Image Formats: WebP and AVIF

Next.js automatically converts images to modern formats:

// next.config.ts
import type { NextConfig } from 'next';
 
const config: NextConfig = {
  images: {
    formats: ['image/avif', 'image/webp'], // ✅ Try AVIF first, fallback to WebP
    deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
    imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
  },
};
 
export default config;

External Images

If using external image CDN:

// next.config.ts
const config: NextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'images.unsplash.com',
        port: '',
        pathname: '/**',
      },
      {
        protocol: 'https',
        hostname: 'cdn.example.com',
      },
    ],
  },
};

Image Loading Strategies

// ✅ Blur placeholder for better UX
import Image from 'next/image';
import placeholder from './placeholder.jpg';
 
export function ImageWithPlaceholder() {
  return (
    <Image
      src="/photo.jpg"
      alt="Photo"
      width={800}
      height={600}
      placeholder="blur"
      blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRg..." // or import static image
    />
  );
}
 
// ✅ Dynamic blur with plaiceholder library
import { getPlaiceholder } from 'plaiceholder';
 
async function getImageWithPlaceholder(src: string) {
  const buffer = await fetch(src).then(res => res.arrayBuffer());
  const { base64 } = await getPlaiceholder(Buffer.from(buffer));
  return base64;
}

Code Splitting

Code splitting helps reduce initial bundle size by loading code on-demand.

Dynamic Imports

// ✅ Dynamic import for non-critical components
import dynamic from 'next/dynamic';
 
// Load component only when needed
const HeavyChart = dynamic(() => import('@/components/HeavyChart'), {
  loading: () => <div>Loading chart...</div>,
  ssr: false, // Disable SSR if component only runs client-side
});
 
export function Dashboard() {
  return (
    <div>
      <h1>Dashboard</h1>
      <HeavyChart data={chartData} />
    </div>
  );
}

Route-based Code Splitting

Next.js automatically splits code by routes:

// app/dashboard/page.tsx
// Each page is automatically a separate chunk
export default function DashboardPage() {
  return <div>Dashboard</div>;
}
 
// app/settings/page.tsx
// Code for this page only loads when user visits /settings
export default function SettingsPage() {
  return <div>Settings</div>;
}

Component-level Splitting

'use client';
 
import { useState } from 'react';
import dynamic from 'next/dynamic';
 
// ✅ Lazy load modal component
const EditModal = dynamic(() => import('./EditModal'), {
  ssr: false,
});
 
export function UserProfile() {
  const [showModal, setShowModal] = useState(false);
 
  return (
    <div>
      <button onClick={() => setShowModal(true)}>Edit Profile</button>
      {/* Modal component only loads when user clicks button */}
      {showModal && <EditModal onClose={() => setShowModal(false)} />}
    </div>
  );
}

Third-party Library Splitting

// ❌ Bad: Import entire library
import _ from 'lodash';
 
function Component() {
  return _.debounce(() => {}, 300);
}
 
// ✅ Good: Import specific functions
import debounce from 'lodash/debounce';
 
function Component() {
  return debounce(() => {}, 300);
}
 
// ✅ Better: Dynamic import for heavy libraries
async function heavyOperation() {
  const { default: moment } = await import('moment');
  return moment().format('YYYY-MM-DD');
}

Font Optimization

Fonts can cause significant layout shifts and delay rendering.

next/font - Built-in Font Optimization

// app/layout.tsx
import { Inter, Fira_Code } from 'next/font/google';
 
// ✅ Load Google Fonts with optimization
const inter = Inter({
  subsets: ['latin', 'vietnamese'], // Only load necessary subsets
  display: 'swap', // Font display strategy
  variable: '--font-inter', // CSS variable
  preload: true,
});
 
const firaCode = Fira_Code({
  subsets: ['latin'],
  display: 'swap',
  variable: '--font-fira-code',
});
 
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" className={`${inter.variable} ${firaCode.variable}`}>
      <body className="font-sans">{children}</body>
    </html>
  );
}
/* globals.css */
:root {
  --font-inter: 'Inter', sans-serif;
  --font-fira-code: 'Fira Code', monospace;
}
 
body {
  font-family: var(--font-inter);
}
 
code {
  font-family: var(--font-fira-code);
}

Local Fonts

// app/layout.tsx
import localFont from 'next/font/local';
 
const myFont = localFont({
  src: [
    {
      path: '../public/fonts/MyFont-Regular.woff2',
      weight: '400',
      style: 'normal',
    },
    {
      path: '../public/fonts/MyFont-Bold.woff2',
      weight: '700',
      style: 'normal',
    },
  ],
  variable: '--font-my-font',
  display: 'swap',
});
 
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" className={myFont.variable}>
      <body>{children}</body>
    </html>
  );
}

Font Display Strategies

const font = Inter({
  subsets: ['latin'],
  display: 'swap', // Options: auto, block, swap, fallback, optional
});
 
/**
 * Display strategies:
 * - auto: Browser default
 * - block: Block text rendering (FOIT) - 3s max
 * - swap: Show fallback immediately (FOUT)
 * - fallback: 100ms block, 3s swap
 * - optional: 100ms block, no swap (use fallback if font not loaded)
 * 
 * Recommended: 'swap' for most cases
 */

Measuring Performance

Lighthouse

# CLI tool
npm install -g lighthouse
 
# Run Lighthouse
lighthouse https://your-site.com --view
 
# Or in Chrome DevTools
# 1. Open DevTools (F12)
# 2. Navigate to "Lighthouse" tab
# 3. Click "Analyze page load"

Chrome DevTools Performance Tab

// Performance monitoring in code
export function monitorPerformance() {
  if (typeof window !== 'undefined' && 'performance' in window) {
    // Navigation Timing
    const navTiming = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
    console.log('DOM Content Loaded:', navTiming.domContentLoadedEventEnd - navTiming.fetchStart);
    console.log('Page Load Time:', navTiming.loadEventEnd - navTiming.fetchStart);
    
    // Paint Timing
    const paintEntries = performance.getEntriesByType('paint');
    paintEntries.forEach(entry => {
      console.log(`${entry.name}:`, entry.startTime);
    });
  }
}

Web Vitals Library

// app/layout.tsx
import { Suspense } from 'react';
import { WebVitals } from '@/components/web-vitals';
 
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        {children}
        <Suspense fallback={null}>
          <WebVitals />
        </Suspense>
      </body>
    </html>
  );
}
// components/web-vitals.tsx
'use client';
 
import { useEffect } from 'react';
import { onCLS, onFID, onFCP, onLCP, onTTFB, onINP } from 'web-vitals';
 
export function WebVitals() {
  useEffect(() => {
    // Track Core Web Vitals
    onCLS(metric => {
      console.log('CLS:', metric.value);
      // Send to analytics
      sendToAnalytics('CLS', metric.value);
    });
 
    onFID(metric => {
      console.log('FID:', metric.value);
      sendToAnalytics('FID', metric.value);
    });
 
    onINP(metric => {
      console.log('INP:', metric.value);
      sendToAnalytics('INP', metric.value);
    });
 
    onLCP(metric => {
      console.log('LCP:', metric.value);
      sendToAnalytics('LCP', metric.value);
    });
 
    onFCP(metric => {
      console.log('FCP:', metric.value);
      sendToAnalytics('FCP', metric.value);
    });
 
    onTTFB(metric => {
      console.log('TTFB:', metric.value);
      sendToAnalytics('TTFB', metric.value);
    });
  }, []);
 
  return null;
}
 
function sendToAnalytics(metric: string, value: number) {
  // Send to your analytics service
  if (typeof window !== 'undefined' && 'gtag' in window) {
    (window as any).gtag('event', metric, {
      value: Math.round(metric === 'CLS' ? value * 1000 : value),
      metric_id: `${metric}-${Date.now()}`,
      non_interaction: true,
    });
  }
}

Real User Monitoring (RUM)

// lib/analytics.ts
export function initRUM() {
  if (typeof window === 'undefined') return;
 
  // Track page views
  const sendPageView = () => {
    const url = window.location.href;
    const referrer = document.referrer;
    
    fetch('/api/analytics', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        event: 'pageview',
        url,
        referrer,
        timestamp: Date.now(),
      }),
    });
  };
 
  // Send on load
  sendPageView();
 
  // Track SPA navigations
  let lastUrl = window.location.href;
  new MutationObserver(() => {
    const currentUrl = window.location.href;
    if (currentUrl !== lastUrl) {
      lastUrl = currentUrl;
      sendPageView();
    }
  }).observe(document, { subtree: true, childList: true });
}

Performance Checklist

Images

  • [ ] Use Next.js Image component for all images
  • [ ] Specify width and height to avoid CLS
  • [ ] Use priority for above-the-fold images
  • [ ] Lazy load below-the-fold images
  • [ ] Optimize image quality (75-85%)
  • [ ] Use modern formats (WebP, AVIF)
  • [ ] Implement blur placeholders

JavaScript

  • [ ] Enable code splitting with dynamic imports
  • [ ] Minimize third-party scripts
  • [ ] Use React.memo() for expensive components
  • [ ] Implement useMemo/useCallback appropriately
  • [ ] Avoid unnecessary re-renders
  • [ ] Remove unused dependencies
  • [ ] Tree-shake with ES modules

Fonts

  • [ ] Use next/font for font optimization
  • [ ] Preload critical fonts
  • [ ] Use font-display: swap
  • [ ] Subset fonts (only load necessary characters)
  • [ ] Minimize font variants

Caching

  • [ ] Configure proper cache headers
  • [ ] Use ISR (Incremental Static Regeneration)
  • [ ] Implement stale-while-revalidate
  • [ ] Cache API responses
  • [ ] Use CDN for static assets

Monitoring

  • [ ] Track Core Web Vitals
  • [ ] Set up Real User Monitoring
  • [ ] Monitor bundle sizes
  • [ ] Regular Lighthouse audits
  • [ ] Alert on performance regressions

Conclusion

Performance optimization is a continuous process, not a one-time task. By implementing the techniques in this article, you can achieve:

Key Achievements:

  • LCP < 2.5s: Fast content loading with image optimization
  • FID/INP < 100ms/200ms: Responsive interactions with code splitting
  • CLS < 0.1: Stable layouts with proper dimensions
  • Lighthouse Score > 95: Overall excellent performance

Best Practices Summary

  1. Measure First: Don't optimize blindly - always measure first
  2. Focus on User Experience: Metrics are means, not the end goal
  3. Automate Monitoring: Set up continuous performance monitoring
  4. Budget Performance: Set and enforce performance budgets
  5. Stay Updated: Next.js updates often include performance improvements

Performance Budget Example

{
  "budgets": [
    {
      "resourceType": "script",
      "budget": 300
    },
    {
      "resourceType": "image",
      "budget": 500
    },
    {
      "resourceType": "total",
      "budget": 1000
    }
  ]
}

Tools for Continued Optimization

  • Lighthouse CI: Automate performance testing
  • Bundle Analyzer: Analyze bundle composition
  • WebPageTest: Detailed performance analysis
  • Chrome User Experience Report: Real-world performance data
  • Vercel Analytics: Built-in performance monitoring

Resources


Performance questions? Leave a comment below or contact via email. Happy optimizing! 🚀

Cong Dinh

Cong Dinh

Technology Consultant | Trainer | Solution Architect

With over 10 years of experience in web development and cloud architecture, I help businesses build modern and sustainable technology solutions. Expertise: Next.js, TypeScript, AWS, and Solution Architecture.

Related Posts