← All posts

How to Fix Core Web Vitals in Next.js (App Router)

By Harshit Dixit · · 11 min read

Most Core Web Vitals advice for Next.js is a list of switches — use next/image, use next/font, add preload. Those help, but a site that already uses all of them can still fail every metric. The fix is almost always in finding the one element or script that is actually responsible, and that starts with measuring the right thing.

At Bigbasket I worked on a React checkout that handles more than five million transactions a month. Getting its Largest Contentful Paint from 4.2 seconds down to 3.4, and time-to-interactive from 6 seconds to 3.6, didn't come from one big switch. It came from three unglamorous techniques — memoisation, code-splitting, and deferring non-critical assets — each applied where measurement showed it would matter. That pattern — measure, find the specific cause, fix only that — is what this guide is about.

It works through the three metrics in the order they usually need fixing, with the App Router specifics that matter in Next.js 15 and 16.

The three metrics, and what “good” means

MetricWhat it measuresGoodPoor
LCP — Largest Contentful PaintWhen the biggest visible element finishes rendering≤ 2.5 s> 4.0 s
INP — Interaction to Next PaintHow quickly the page visibly responds to taps, clicks and key presses≤ 200 ms> 500 ms
CLS — Cumulative Layout ShiftHow much content moves unexpectedly while the page is open≤ 0.1> 0.25

Google assesses each at the 75th percentile of real visits, split by mobile and desktop. That detail changes how you should debug: a Lighthouse run on a fast laptop is one visit on ideal hardware, and the users who drag your 75th percentile down are on mid-range Android phones and patchy mobile connections.

Step 1: measure with field data first

Start with what real users experience, then use lab tools to reproduce it. There are three sources worth checking:

  • PageSpeed Insights — the top section (“Discover what your real users are experiencing”) is Chrome UX Report field data. The Lighthouse score underneath is a single lab run. If the two disagree, trust the field data.
  • Search Console → Core Web Vitals — groups failing URLs by pattern, which tells you whether the problem is one template or the whole site.
  • Your own real-user monitoring — for pages without enough traffic to appear in CrUX, report the metrics yourself.

Next.js ships a hook for the last one. Put it in a small client component and render it once from the root layout:

// app/web-vitals.js
"use client";

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

export function WebVitals() {
  useReportWebVitals((metric) => {
    // metric.name is "LCP" | "INP" | "CLS" | "FCP" | "TTFB"
    navigator.sendBeacon?.(
      "/api/vitals",
      JSON.stringify({
        name: metric.name,
        value: metric.value,
        rating: metric.rating,
        id: metric.id,
        page: location.pathname,
      })
    );
  });
  return null;
}

If you already run Google Analytics through Tag Manager, sending these as events works just as well. What matters is that you can split the numbers by page and device, because a site-wide average hides the one template that is failing.

Step 2: fix LCP

LCP is usually the metric that fails first, and it is the most mechanical to fix once you know which element it is. In Chrome DevTools, record a trace in the Performance panel with CPU and network throttling on; the LCP marker names the element. It is nearly always a hero image, a large heading, or a background image set in CSS.

LCP time breaks down into four parts, and each has a different fix:

  1. Time to first byte — the server response. If this alone is over about 800 ms, nothing else on this list will save you.
  2. Resource load delay — how long before the browser even starts fetching the LCP image.
  3. Resource load time — how long the download takes.
  4. Render delay — the gap between the image arriving and it being painted, usually caused by render-blocking CSS or JavaScript.

Make the page static where you can

The biggest TTFB win in the App Router is making sure a route that could be pre-rendered actually is. A single call to cookies(), headers() or an uncached fetch anywhere in the tree opts the whole route into per-request rendering. Run next build and check the route table: a marketing page that shows as dynamic is a page paying for a server render on every visit for no reason.

Where a route genuinely needs request data, keep that part small and wrap it in a <Suspense> boundary, so the static shell — including the LCP element — streams immediately. With Cache Components enabled in Next.js 16, the "use cache" directive lets you cache individual components and functions instead of making the whole route one or the other.

Get the LCP image requested early

By default next/image lazy-loads, which is exactly wrong for the hero. Tell it this image matters:

import Image from "next/image";

<Image
  src="/images/hero.webp"
  alt="Clinic reception with patients checking in"
  width={1200}
  height={800}
  fetchPriority="high"
  loading="eager"
  sizes="(max-width: 768px) 100vw, 50vw"
/>

In Next.js 16 the old priority prop is deprecated in favour of preload, and the docs now recommend loading="eager" or fetchPriority="high" for most cases. Use preload only when the image would otherwise be discovered late — for example, when it sits deep in a client component. Only do this for the one image that is actually the LCP element; marking five images as high priority makes them compete with each other.

Two common traps: an LCP image set as a CSS background-imageis invisible to the browser's preload scanner until the CSS is parsed, and a hero rendered inside a carousel that only mounts after hydration cannot start loading until the JavaScript has run. Both show up as a long resource load delay.

Send the right size

The sizes attribute is what lets the browser pick a small file on a phone. Without it, next/image assumes the image may fill the viewport and a phone can end up downloading a desktop-width file. Get sizes right before you spend time on formats — a correctly sized WebP usually beats a wrongly sized AVIF.

Don't let fonts delay text

When the LCP element is a heading, the font often decides LCP. next/font self-hosts the files and preloads them, and with display: "swap" text is painted in the fallback font immediately. Load only the weights you use: each weight is a separate file.

Step 3: fix INP

INP replaced First Input Delay as a Core Web Vital in March 2024, and it is much harder to pass. FID only measured the delay before the first interaction started being handled; INP measures the full time from any interaction to the next frame being painted, across the whole visit, and reports close to the worst one.

Poor INP almost always means the main thread is busy when the user taps. In a Next.js app, that is usually one of four things:

  • Hydration of a large client tree. Every component under a "use client" boundary ships JavaScript and must hydrate. Push the boundary down: keep layouts, headings and static content as Server Components and make only the interactive leaf a Client Component.
  • Third-party scripts. Chat widgets, tag managers, heatmaps and ad scripts run on the same main thread as your handlers. Load anything non-essential with <Script strategy="lazyOnload"> so it waits until the page is idle, and question whether each one earns its cost.
  • Expensive state updates. A keystroke that re-renders a thousand-row list, or a filter that re-sorts everything synchronously. Wrap the non-urgent part in startTransition so React can paint the input first.
  • Long synchronous handlers. Anything that runs for more than 50 ms blocks the next paint. Do the visible update first, then yield before the heavy work.
"use client";
import { useState, useTransition } from "react";

export function ProductFilter({ products }) {
  const [query, setQuery] = useState("");
  const [visible, setVisible] = useState(products);
  const [isPending, startTransition] = useTransition();

  function onChange(e) {
    const next = e.target.value;
    setQuery(next); // urgent: the input updates immediately
    startTransition(() => {
      // non-urgent: React can interrupt this to keep typing responsive
      setVisible(products.filter((p) => p.name.toLowerCase().includes(next.toLowerCase())));
    });
  }

  return (
    <>
      <input value={query} onChange={onChange} aria-label="Filter products" />
      <ProductList items={visible} dimmed={isPending} />
    </>
  );
}

To find the slow interaction, use the Performance panel in DevTools: record, perform the interaction, and look for the long task under it. The field data from useReportWebVitals also tells you which pages have poor INP, which narrows the search considerably.

Step 4: fix CLS

Layout shift is the easiest metric to fix and the easiest to reintroduce. Nearly every shift comes from something taking up space it didn't reserve:

  • Images without dimensions. next/image requires width and height (or fill inside a sized container) precisely so the browser can reserve the box. A plain <img> in Markdown or CMS content often has neither.
  • Fonts swapping. When the web font replaces the fallback and the text reflows. next/font generates a size-adjusted fallback to minimise this — another reason to use it rather than a <link> to Google Fonts.
  • Content injected above existing content. Cookie banners, promo bars and “download our app” strips that push the page down after it has rendered. Overlay them, or reserve their height in the initial HTML.
  • Embeds and ads. Give the container a fixed min-height or an aspect-ratio before the iframe loads.
  • Client-only rendering after hydration. A component that renders nothing on the server and something on the client (for example, based on window.innerWidth) shifts everything below it. Use CSS media queries for layout differences instead.

A checklist to work through

  1. Check field data in PageSpeed Insights and Search Console; note which metric fails, on which device.
  2. Run next build and confirm marketing routes are static.
  3. Identify the LCP element in a throttled DevTools trace.
  4. Give that one image fetchPriority="high" and a correct sizes; make sure it is in the server-rendered HTML.
  5. Move "use client" boundaries down to the smallest interactive components.
  6. Audit third-party scripts; lazy-load or remove what you can.
  7. Wrap expensive, non-urgent updates in startTransition.
  8. Reserve space for every image, embed and injected banner.
  9. Ship, then watch the field data — CrUX is a rolling 28-day window, so give it a few weeks.

That last point catches people out: after a fix, Search Console's report won't move for weeks, because the field data is a rolling 28-day window. Your own useReportWebVitals numbers will move within days, which is the best reason to set it up before you start.

If your site is failing and you'd rather have someone find the cause and ship the fix, that's exactly what a technical SEO and performance audit covers.

// free, no strings

Book a free consultation

30 minutes, no cost, no obligation — pick a slot that works for you.

  • ›Sign in with Google to book instantly
  • ›Or just email/WhatsApp if you'd rather
  • ›We'll follow up with a free draft, not a sales pitch