Next.js

Performance Optimization in Next.js

42 min Lesson 25 of 40

Understanding Performance in Next.js

Performance optimization is crucial for delivering fast, responsive web applications. Next.js provides numerous built-in features and tools to help optimize your application's performance. This lesson covers advanced techniques for analyzing and improving performance, focusing on Core Web Vitals and modern optimization strategies.

Core Web Vitals Overview

Core Web Vitals are Google's metrics for measuring user experience:

  • LCP (Largest Contentful Paint): Measures loading performance. Should occur within 2.5 seconds.
  • FID (First Input Delay): Measures interactivity. Should be less than 100 milliseconds.
  • CLS (Cumulative Layout Shift): Measures visual stability. Should be less than 0.1.
  • INP (Interaction to Next Paint): Replaces FID, measures responsiveness. Should be less than 200ms.
Important: Core Web Vitals directly impact SEO rankings and user satisfaction. Prioritize optimizing these metrics.

Image Optimization

The Next.js Image component provides automatic optimization:

// Optimized image loading
import Image from 'next/image';

export default function ProductCard({ product }) {
  return (
    <div className="product-card">
      {/* Automatic optimization, lazy loading, and responsive images */}
      <Image
        src={product.image}
        alt={product.name}
        width={400}
        height={300}
        quality={85}
        placeholder="blur"
        blurDataURL={product.blurHash}
        priority={product.featured} // Load featured images immediately
        sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
      />
      <h3>{product.name}</h3>
      <p>${product.price}</p>
    </div>
  );
}

// Generate blur placeholder
import { getPlaiceholder } from 'plaiceholder';

async function getBase64(imageUrl: string) {
  try {
    const res = await fetch(imageUrl);
    const buffer = await res.arrayBuffer();
    const { base64 } = await getPlaiceholder(Buffer.from(buffer));
    return base64;
  } catch {
    return 'data:image/svg+xml;base64,...'; // Fallback
  }
}

Font Optimization

Optimize web fonts with next/font:

// app/layout.tsx
import { Inter, Roboto_Mono } from 'next/font/google';
import localFont from 'next/font/local';

// Google Fonts with automatic optimization
const inter = Inter({
  subsets: ['latin'],
  display: 'swap', // Prevent invisible text during loading
  variable: '--font-inter',
  preload: true,
});

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

// Local custom fonts
const myFont = localFont({
  src: './fonts/my-font.woff2',
  display: 'swap',
  variable: '--font-custom',
});

export default function RootLayout({ children }) {
  return (
    <html lang="en" className={`${inter.variable} ${robotoMono.variable} ${myFont.variable}`}>
      <body className={inter.className}>{children}</body>
    </html>
  );
}
Tip: Use font-display: swap to prevent invisible text during font loading, improving perceived performance.

Bundle Analysis

Analyze your bundle size to identify optimization opportunities:

# Install bundle analyzer
npm install -D @next/bundle-analyzer

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

module.exports = withBundleAnalyzer({
  // Your Next.js config
  reactStrictMode: true,
  swcMinify: true,
});

# Run bundle analysis
ANALYZE=true npm run build

# This opens interactive visualizations of your bundle

Code Splitting and Dynamic Imports

Split code to reduce initial bundle size:

// Dynamic import for heavy components
import dynamic from 'next/dynamic';

// Load component only when needed
const HeavyChart = dynamic(() => import('@/components/HeavyChart'), {
  loading: () => <p>Loading chart...</p>,
  ssr: false, // Disable server-side rendering if not needed
});

// Dynamic import with named exports
const DynamicModal = dynamic(
  () => import('@/components/Modal').then((mod) => mod.Modal),
  {
    loading: () => <div className="modal-skeleton" />,
  }
);

export default function Dashboard() {
  const [showChart, setShowChart] = useState(false);

  return (
    <div>
      <h1>Dashboard</h1>
      <button onClick={() => setShowChart(true)}>Show Chart</button>
      {showChart && <HeavyChart data={data} />}
    </div>
  );
}

Lazy Loading with React.lazy

Use React's built-in lazy loading for components:

import { lazy, Suspense } from 'react';

// Lazy load components
const CommentSection = lazy(() => import('@/components/CommentSection'));
const RelatedProducts = lazy(() => import('@/components/RelatedProducts'));

export default function ProductPage({ product }) {
  return (
    <main>
      <h1>{product.name}</h1>
      <p>{product.description}</p>

      {/* Load comments only when scrolled into view */}
      <Suspense fallback={<CommentsSkeleton />}>
        <CommentSection productId={product.id} />
      </Suspense>

      {/* Load related products lazily */}
      <Suspense fallback={<ProductsSkeleton />}>
        <RelatedProducts category={product.category} />
      </Suspense>
    </main>
  );
}

Script Optimization

Optimize third-party script loading:

import Script from 'next/script';

export default function Layout({ children }) {
  return (
    <html>
      <body>
        {children}

        {/* Load after page is interactive */}
        <Script
          src="https://analytics.example.com/script.js"
          strategy="lazyOnload"
        />

        {/* Load before page is interactive (for critical scripts) */}
        <Script
          src="https://checkout.stripe.com/v3.js"
          strategy="beforeInteractive"
        />

        {/* Load after hydration (default) */}
        <Script
          src="https://widget.example.com/chat.js"
          strategy="afterInteractive"
          onLoad={() => {
            console.log('Chat widget loaded');
          }}
        />

        {/* Inline scripts */}
        <Script id="analytics" strategy="afterInteractive">
          {`
            window.dataLayer = window.dataLayer || [];
            function gtag(){dataLayer.push(arguments);}
            gtag('js', new Date());
            gtag('config', 'GA_MEASUREMENT_ID');
          `}
        </Script>
      </body>
    </html>
  );
}
Warning: Third-party scripts can significantly impact performance. Always use the Script component with appropriate strategies instead of regular <script> tags.

Measuring Performance with Lighthouse

Use Lighthouse to measure and improve Core Web Vitals:

# Install Lighthouse CLI
npm install -g lighthouse

# Run Lighthouse audit
lighthouse https://your-site.com --view

# Generate JSON report
lighthouse https://your-site.com --output=json --output-path=./report.json

# Run in CI/CD
lighthouse https://your-site.com --chrome-flags="--headless" --output=json

Web Vitals Monitoring

Track Core Web Vitals in production:

// app/layout.tsx
import { Suspense } from 'react';
import { WebVitals } from '@/components/WebVitals';

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        {children}
        <Suspense>
          <WebVitals />
        </Suspense>
      </body>
    </html>
  );
}

// components/WebVitals.tsx
'use client';

import { useReportWebVitals } from 'next/web-vitals';

export function WebVitals() {
  useReportWebVitals((metric) => {
    // Send to analytics
    console.log(metric);

    // Send to Google Analytics
    if (window.gtag) {
      window.gtag('event', metric.name, {
        value: Math.round(metric.name === 'CLS' ? metric.value * 1000 : metric.value),
        event_label: metric.id,
        non_interaction: true,
      });
    }

    // Send to custom analytics endpoint
    fetch('/api/analytics/web-vitals', {
      method: 'POST',
      body: JSON.stringify(metric),
      headers: { 'Content-Type': 'application/json' },
    });
  });

  return null;
}

Optimizing Server Components

Leverage Server Components for better performance:

// Server Component (default in app directory)
async function ProductList() {
  // Fetch on server - no client bundle impact
  const products = await fetch('https://api.example.com/products', {
    next: { revalidate: 3600 }, // Cache for 1 hour
  }).then(r => r.json());

  return (
    <div className="product-grid">
      {products.map((product) => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  );
}

// Keep client components small and focused
'use client';

function ProductCard({ product }) {
  const [liked, setLiked] = useState(false);

  return (
    <div>
      <h3>{product.name}</h3>
      {/* Only interactive parts need client-side JS */}
      <button onClick={() => setLiked(!liked)}>
        {liked ? '❤️' : '🤍'}
      </button>
    </div>
  );
}

Streaming and Suspense

Stream content progressively for faster perceived performance:

import { Suspense } from 'react';

// Show instant shell, stream content as it loads
export default function Page() {
  return (
    <main>
      {/* Instant: Static content */}
      <h1>Product Details</h1>

      {/* Stream 1: Fast API */}
      <Suspense fallback={<ProductSkeleton />}>
        <ProductInfo />
      </Suspense>

      {/* Stream 2: Slower API */}
      <Suspense fallback={<ReviewsSkeleton />}>
        <ProductReviews />
      </Suspense>

      {/* Stream 3: Even slower API */}
      <Suspense fallback={<RecommendationsSkeleton />}>
        <Recommendations />
      </Suspense>
    </main>
  );
}

// Each component streams independently
async function ProductReviews() {
  const reviews = await fetchReviews(); // Can be slow
  return <ReviewsList reviews={reviews} />;
}
Tip: Use Suspense boundaries strategically to show content progressively. Users see something useful faster instead of waiting for everything to load.

Prefetching and Preloading

Optimize navigation with prefetching:

import Link from 'next/link';

export default function Navigation() {
  return (
    <nav>
      {/* Automatic prefetching on hover/viewport */}
      <Link href="/products" prefetch={true}>
        Products
      </Link>

      {/* Disable prefetch for less important pages */}
      <Link href="/terms" prefetch={false}>
        Terms
      </Link>
    </nav>
  );
}

// Manual prefetching with router
'use client';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';

export default function SmartPrefetch() {
  const router = useRouter();

  useEffect(() => {
    // Prefetch after user has been idle for 2 seconds
    const timer = setTimeout(() => {
      router.prefetch('/checkout');
    }, 2000);

    return () => clearTimeout(timer);
  }, [router]);

  return <button>Go to Checkout</button>;
}

Database Query Optimization

Optimize data fetching patterns:

// Parallel data fetching
async function getPageData(id: string) {
  // Fetch in parallel instead of sequentially
  const [product, reviews, recommendations] = await Promise.all([
    fetchProduct(id),
    fetchReviews(id),
    fetchRecommendations(id),
  ]);

  return { product, reviews, recommendations };
}

// Selective field fetching
async function getProducts() {
  // Only fetch needed fields
  const products = await prisma.product.findMany({
    select: {
      id: true,
      name: true,
      price: true,
      image: true,
      // Don't fetch heavy fields like description
    },
    take: 20, // Limit results
    orderBy: { createdAt: 'desc' },
  });

  return products;
}

// Use dataloader pattern for N+1 prevention
import DataLoader from 'dataloader';

const userLoader = new DataLoader(async (userIds) => {
  const users = await prisma.user.findMany({
    where: { id: { in: userIds } },
  });

  return userIds.map((id) => users.find((u) => u.id === id));
});

Edge Runtime for API Routes

Use Edge Runtime for globally distributed low-latency responses:

// app/api/fast/route.ts
export const runtime = 'edge';

export async function GET(request: Request) {
  // Runs on edge network close to users
  const data = await fetch('https://api.example.com/data').then(r => r.json());

  return Response.json(data);
}

// Example: Geolocation-based responses
export async function GET(request: Request) {
  const country = request.headers.get('x-vercel-ip-country') || 'US';
  const city = request.headers.get('x-vercel-ip-city') || 'Unknown';

  return Response.json({
    message: `Hello from ${city}, ${country}!`,
    timestamp: Date.now(),
  });
}

Compression and Minification

Configure automatic optimization:

// next.config.js
module.exports = {
  // Enable SWC minification (default in Next.js 13+)
  swcMinify: true,

  // Compress images
  images: {
    formats: ['image/avif', 'image/webp'],
    minimumCacheTTL: 60,
  },

  // Enable compression
  compress: true,

  // Remove console.log in production
  compiler: {
    removeConsole: process.env.NODE_ENV === 'production',
  },

  // Optimize package imports
  modularizeImports: {
    'lodash': {
      transform: 'lodash/{{member}}',
    },
  },
};

Caching Headers

Configure caching headers for optimal performance:

// next.config.js
module.exports = {
  async headers() {
    return [
      {
        source: '/images/:path*',
        headers: [
          {
            key: 'Cache-Control',
            value: 'public, max-age=31536000, immutable',
          },
        ],
      },
      {
        source: '/_next/static/:path*',
        headers: [
          {
            key: 'Cache-Control',
            value: 'public, max-age=31536000, immutable',
          },
        ],
      },
      {
        source: '/api/:path*',
        headers: [
          {
            key: 'Cache-Control',
            value: 'public, s-maxage=60, stale-while-revalidate=120',
          },
        ],
      },
    ];
  },
};

Practice Exercise

Task: Optimize a slow e-commerce product page:

  1. Run Lighthouse audit and identify performance bottlenecks
  2. Optimize all images with next/image and blur placeholders
  3. Lazy load the reviews section with dynamic imports
  4. Implement streaming with Suspense for recommendations
  5. Set up Web Vitals monitoring with analytics
  6. Analyze bundle size and reduce it by at least 20%
  7. Configure proper cache headers for static assets
  8. Achieve Lighthouse performance score above 90

Bonus: Implement edge middleware for A/B testing without impacting performance.

Summary

Key takeaways about performance optimization in Next.js:

  • Prioritize Core Web Vitals: LCP, FID/INP, and CLS
  • Use next/image for automatic image optimization
  • Optimize fonts with next/font and font-display: swap
  • Analyze and reduce bundle size with dynamic imports
  • Use Server Components to reduce client-side JavaScript
  • Implement progressive streaming with Suspense
  • Monitor real-world performance with Web Vitals tracking
  • Configure caching headers for optimal asset delivery