Tutorial: Optimizing Core Web Vitals with Next.js App Router

Learn how to optimize Core Web Vitals in Next.js 14/15 App Router applications with practical techniques for LCP, INP, and CLS.

Prerequisites

Step 1: Optimize LCP with Next.js Image Component

import Image from 'next/image';

export default function Hero() {
  return (
    <div className="hero">
      <Image
        src="/hero.jpg"
        alt="Hero image"
        width={1200}
        height={630}
        priority // Preload this image
        sizes="100vw"
        className="hero-image"
      />
    </div>
  );
}

Image Optimization Configuration

// next.config.js
module.exports = {
  images: {
    formats: ['image/avif', 'image/webp'],
    deviceSizes: [640, 750, 828, 1080, 1200, 1920],
    imageSizes: [16, 32, 48, 64, 96, 128, 256],
  },
};

Step 2: Optimize Font Loading

// app/layout.js
import { Inter } from 'next/font/google';

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

export default function RootLayout({ children }) {
  return (
    <html lang="en" className={inter.variable}>
      <body className={inter.className}>{children}</body>
    </html>
  );
}

Next.js automatically:

Step 3: Reduce INP with Server Components

// Move heavy computations to server
// app/articles/page.js
async function ArticlesPage() {
  // This runs on the server - zero client JS
  const articles = await fetchArticles();
  const processedData = heavyComputation(articles);

  return (
    <div>
      {processedData.map(article => (
        <ArticleCard key={article.id} article={article} />
      ))}
    </div>
  );
}

Client Component Optimization

'use client';
import { useTransition, useMemo } from 'react';

export function SearchResults({ query }) {
  const [isPending, startTransition] = useTransition();

  const filteredResults = useMemo(() => {
    return expensiveFilter(query);
  }, [query]);

  return (
    <div style={{ opacity: isPending ? 0.7 : 1 }}>
      {filteredResults.map(result => (
        <ResultCard key={result.id} result={result} />
      ))}
    </div>
  );
}

Step 4: Prevent CLS with Streaming SSR

// app/page.js
import { Suspense } from 'react';

export default function Page() {
  return (
    <main>
      <HeroSection /> {/* Static, renders immediately */}
      <Suspense fallback={<ProductSkeleton />}>
        <ProductList /> {/* Dynamic, streams in */}
      </Suspense>
      <Suspense fallback={<ReviewSkeleton />}>
        <Reviews /> {/* Independent suspense boundary */}
      </Suspense>
    </main>
  );
}

Skeleton Components

function ProductSkeleton() {
  return (
    <div className="grid grid-cols-3 gap-4" aria-busy="true">
      {Array.from({ length: 6 }).map((_, i) => (
        <div key={i} className="animate-pulse">
          <div className="bg-gray-200 rounded-lg h-48 w-full" />
          <div className="bg-gray-200 rounded h-4 w-3/4 mt-4" />
          <div className="bg-gray-200 rounded h-4 w-1/2 mt-2" />
        </div>
      ))}
    </div>
  );
}

Step 5: Implement Route Prefetching

import Link from 'next/link';

// Next.js prefetches linked routes in viewport
export function Navigation() {
  return (
    <nav>
      <Link href="/products" prefetch={true}>Products</Link>
      <Link href="/about" prefetch={false}>About</Link>
    </nav>
  );
}

Step 6: Configure Caching Headers

// next.config.js
module.exports = {
  async headers() {
    return [
      {
        source: '/:path*',
        headers: [
          {
            key: 'Cache-Control',
            value: 'public, max-age=3600, stale-while-revalidate=86400',
          },
        ],
      },
    ];
  },
};

Step 7: Performance Monitoring

// app/layout.js - Report Web Vitals
export function reportWebVitals(metric) {
  fetch('/api/vitals', {
    method: 'POST',
    body: JSON.stringify(metric),
  });
}

Results Summary

Metric Before After
LCP 4.2s 1.8s
INP 350ms 120ms
CLS 0.25 0.05
Bundle Size 380KB 145KB
FCP 2.8s 0.9s