Akforges
← All posts
Next.jsPerformanceSEOCore Web Vitals

Core Web Vitals for Next.js App Router: The Complete Fix Guide

LCP, CLS, and INP failing on your Next.js app? This guide covers the exact causes and fixes for each metric, with code examples for App Router.

April 10, 202612 min readAkforges Studio

Core Web Vitals directly affect Google rankings. A poor score doesn't just mean a slow site — it means lower organic traffic. Here's a systematic guide to diagnosing and fixing CWV issues on Next.js App Router projects.


The three metrics (and what actually causes them to fail)

LCP (Largest Contentful Paint) — time until the largest visible element is painted. Target: under 2.5s.

Most common causes on Next.js:

  • Hero image not using next/image with priority
  • LCP image being lazy-loaded
  • Slow server response time (TTFB)
  • Render-blocking fonts

CLS (Cumulative Layout Shift) — unexpected layout movement. Target: under 0.1.

Most common causes:

  • Images without width/height (or aspect-ratio)
  • Fonts causing FOUT (Flash of Unstyled Text)
  • Dynamic content injected above existing content
  • Ad slots without reserved space

INP (Interaction to Next Paint) — responsiveness to user interaction. Target: under 200ms. Replaced FID in March 2024.

Most common causes:

  • Long Tasks blocking the main thread
  • Heavy client-side JavaScript on interaction
  • Unoptimised re-renders in React

Fixing LCP

1. Use next/image with priority for your hero image

import Image from "next/image";

export default function Hero() {
  return (
    <Image
      src="/hero.webp"
      alt="Hero image"
      width={1200}
      height={630}
      priority          // disables lazy loading, adds fetchpriority="high"
      sizes="100vw"
    />
  );
}

priority adds fetchpriority="high" and removes the loading="lazy" attribute. Your LCP image must have this. Without it, the browser treats it as a low-priority resource.

2. Preload the LCP image

If your LCP element is a CSS background image (not an <img>), next/image won't help. Add a preload hint in your layout:

// app/layout.tsx
import { Metadata } from "next";

export default function RootLayout({ children }) {
  return (
    <html>
      <head>
        <link
          rel="preload"
          as="image"
          href="/hero.webp"
          imageSrcSet="/hero-480.webp 480w, /hero-1200.webp 1200w"
          imageSizes="100vw"
        />
      </head>
      <body>{children}</body>
    </html>
  );
}

3. Reduce Time to First Byte (TTFB)

TTFB > 600ms will push LCP past the 2.5s threshold regardless of other optimisations.

Diagnose with: curl -w "%{time_starttransfer}\n" -o /dev/null -s https://yoursite.com

Common fixes:

  • Enable ISR or static generation for high-traffic pages (avoid dynamic = "force-dynamic" unless necessary)
  • Move to an edge-compatible region closer to your users
  • Add Cache-Control: s-maxage=3600, stale-while-revalidate=86400 headers for static content

4. Self-host your fonts

Google Fonts adds a DNS lookup and connection to fonts.googleapis.com. Use next/font/google instead — it downloads the font at build time and self-hosts it:

import { Geist } from "next/font/google";

const geist = Geist({
  subsets: ["latin"],
  variable: "--font-sans",
  display: "swap",      // prevents FOIT (flash of invisible text)
});

next/font automatically adds font-display: swap and removes the external request.


Fixing CLS

1. Always specify image dimensions

// Wrong — CLS
<img src="/photo.jpg" alt="Photo" />

// Right — no CLS
<Image src="/photo.jpg" alt="Photo" width={800} height={600} />

// Or with aspect-ratio if dimensions vary
<div style={{ aspectRatio: "4/3", position: "relative" }}>
  <Image src="/photo.jpg" alt="Photo" fill sizes="800px" />
</div>

2. Reserve space for dynamic content

If you're injecting content after load (cookie banners, notification bars, deferred widgets):

.cookie-banner {
  /* Reserve space even before content loads */
  min-height: 64px;
  position: fixed;
  bottom: 0;
  /* or use position: fixed to avoid layout shift entirely */
}

Fixed or sticky positioned elements don't cause CLS because they don't affect document flow.

3. Font FOUT causes CLS

When a fallback font has different metrics to the web font, swapping causes layout shift. Use next/font's adjustFontFallback option or manually define font metrics:

const instrumentSerif = Instrument_Serif({
  weight: ["400"],
  subsets: ["latin"],
  variable: "--font-serif",
  display: "swap",
  adjustFontFallback: true,  // auto-generates fallback with matching metrics
});

Fixing INP

INP replaced FID in March 2024. FID only measured the delay to start processing an interaction; INP measures the full time from interaction to the next frame painted.

1. Break up Long Tasks

Any JavaScript task over 50ms blocks the main thread and delays interaction response. Use the scheduler to yield between chunks:

async function processLargeDataset(items: Item[]) {
  for (let i = 0; i < items.length; i++) {
    processItem(items[i]);

    // Yield to browser every 50 items
    if (i % 50 === 0) {
      await scheduler.yield(); // Chrome 115+
      // Fallback: await new Promise(r => setTimeout(r, 0));
    }
  }
}

2. Defer non-critical JavaScript

Next.js App Router server components help here — components that don't need interactivity don't ship JavaScript. But for third-party scripts:

import Script from "next/script";

// strategy="lazyOnload" waits until browser is idle
<Script src="https://analytics.example.com/script.js" strategy="lazyOnload" />

// strategy="afterInteractive" loads after hydration
<Script src="https://tag-manager.example.com/gtm.js" strategy="afterInteractive" />

Never use strategy="beforeInteractive" for analytics or tag managers.

3. Optimise React re-renders triggered by interactions

INP failures often come from a click handler that triggers a large component tree re-render. Profile with React DevTools Profiler — look for components re-rendering that shouldn't be.

Common fixes:

  • useMemo for expensive computed values
  • useCallback for stable function references passed as props
  • React.memo for pure components that receive the same props on re-render
  • Move state down to the smallest subtree that needs it

The measurement workflow

  1. Baseline: Run PageSpeed Insights on your 3 most important pages. Record LCP, CLS, INP.
  2. Lab testing: npx unlighthouse for a site-wide Lighthouse audit — scans every page automatically.
  3. Field data: Check Search Console → Core Web Vitals report. Lab scores and field scores differ — field data is what Google uses for ranking.
  4. After fixes: Deploy, wait 28 days (Google's rolling average window), re-check Search Console.

Quick wins by impact

Fix these first — they usually move the needle most:

  1. Add priority to your LCP image (next/image) — 15–30% LCP improvement
  2. Switch from <img> to next/image with explicit dimensions — eliminates most CLS
  3. Move to next/font from manual <link> Google Fonts — removes external request, fixes FOUT
  4. Set strategy="lazyOnload" on all third-party scripts — INP improvement + LCP improvement
  5. Check dynamic = "force-dynamic" pages — convert to ISR where possible — TTFB improvement

If you've fixed the obvious issues and CWV still isn't where you need it, the remaining gains usually come from TTFB and Long Tasks — both require profiling with real traffic data. We do full technical SEO engagements that include CWV diagnosis and implementation.

Work with us

Need help applying this to your stack?

Free 30-min strategy call. We'll scope your problem and tell you honestly what the fix looks like.

Book a strategy call