Skip to content
Quality Assurance Labs
Web Development

Next.js Core Web Vitals: 5 Measured Fixes for App Router

Kawsher, Founder, QA Labs13 min readPublished Updated

We fixed LCP, CLS and INP on a Next.js 16 App Router page and measured each change: Lighthouse mobile 84 to 97, slowest keystroke 232 ms to 16 ms. Code included.

Laptop showing a web page layout beside code on a second screen
  • Next.js
  • Core-Web-Vitals
  • LCP
  • INP
  • CLS
  • web-performance

Next.js gives you fast pages by default, until real product code arrives. A catalog gets imported into a client component, a marketing banner appears after load, a chat widget is pasted into the layout, and a search box re-renders hundreds of cards on every keystroke. None of these breaks a build or a test, but each one quietly moves a Core Web Vital in the wrong direction.

To see how much each mistake really costs, we built a small Next.js 16 App Router shop with a 5,000-product catalog in three versions: one with five common mistakes, one with the popular half-fix for third-party scripts, and one with every fix applied. We measured all three with Lighthouse's mobile preset (seven runs each, medians) and with a Playwright test on an emulated mid-range phone over slow 4G.

The fixed page scored 97 on Lighthouse mobile, against 84 for the original. The hero image's real load time on slow 4G dropped from 7.9 seconds to 0.8 seconds, the slowest keystroke went from 232 ms to 16 ms, and layout shift went to zero. The most surprising result was the half-fix: loading the third-party script with strategy="lazyOnload" made the score worse (76), not better.

Below are the five fixes in the order we applied them, the code for each, and the measured effect.

The three Core Web Vitals, and what moves them in Next.js

Google judges a page on the 75th percentile of real visits. A page passes when all three metrics are in the good range:

  • Largest Contentful Paint (LCP): 2.5 seconds or less. In most Next.js pages the LCP element is the hero image or the main heading, so image loading and render-blocking work decide it.
  • Interaction to Next Paint (INP): 200 ms or less. It measures how quickly the page responds to clicks, taps and key presses, so the amount of JavaScript and how much each interaction re-renders decide it.
  • Cumulative Layout Shift (CLS): 0.1 or less. Anything that appears after the first paint without reserved space, such as banners, images without dimensions and late-loading embeds, adds to it.

Lighthouse also reports Total Blocking Time (TBT), the lab stand-in for INP: the main-thread time after first paint that was blocked by tasks longer than 50 ms. It carries the largest weight in the Lighthouse performance score, which is why third-party scripts show up so clearly in the results below.

The test page: one shop, three versions

The page is a typical store landing page: a header, a promotional banner, a large hero photo, a heading, a search box and a product grid. The /before version contains five mistakes we regularly find in Next.js audits:

  • The whole page is a client component, so the 1 MB product catalog (a JSON file in the repository) is bundled into the JavaScript.
  • The hero is a plain img tag: a 1.3 MB, 2400-pixel JPEG with no width, height, responsive sizes or priority.
  • The promotional banner is inserted above the content 800 ms after load, pushing the page down.
  • A third-party widget script is placed straight in the page body and runs about 250 ms of main-thread work (simulated, like a chat or A/B-testing tag).
  • Every keystroke filters the catalog and re-renders up to 500 cards, creating a new Intl.NumberFormat for every card.
"use client";
import { useEffect, useState } from "react";
import { PRODUCTS } from "../../lib/products"; // 1 MB JSON catalog, now inside the client bundle

export default function Before() {
  const [query, setQuery] = useState("");
  const [showBanner, setShowBanner] = useState(false);
  useEffect(() => { const t = setTimeout(() => setShowBanner(true), 800); return () => clearTimeout(t); }, []);

  const results = PRODUCTS.filter((p) => p.name.toLowerCase().includes(query.toLowerCase())).slice(0, 500);

  return (
    <>
      {showBanner && <div className="banner">Free shipping on orders over $50 this week only!</div>}
      <script src="/vendor/heavy-widget.js"></script>
      <img className="hero-img" src="/hero.jpg" alt="Studio photo of camera gear on a desk" />
      <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search 5,000 products" />
      {results.map((p) => (
        <div className="card" key={p.id}>
          <h3>{p.name}</h3>
          <p>{new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(p.price)}</p>
        </div>
      ))}
    </>
  );
}

The /after-lazyonload version applies every fix except one: it loads the widget with next/script and strategy="lazyOnload" instead of removing it from the initial load. The /after version replaces the widget with a facade, described in Fix 4.

Fix 1: Keep large data on the server with Server Components

In the App Router, a page is a Server Component unless you mark it "use client". Marking the whole page as a client component is the quickest way to get a search box working, and the most expensive: every module the page imports, including a 1 MB catalog, is shipped to the browser and parsed on every visit.

The fix is to keep the page on the server and move interactivity into a small client island. The server renders the first 24 products; the island only holds the input and fetches matching products from a route handler.

// app/after/page.tsx: a Server Component. The catalog never reaches the browser.
import { search } from "../../lib/products";
import { ProductGrid } from "../../components/ProductGrid";
import { SearchIsland } from "../../components/SearchIsland";

export default function After() {
  const initial = search("");          // first 24 products, rendered on the server
  return (
    <main>
      <h1>Gear for creators</h1>
      <SearchIsland initial={initial}>
        <ProductGrid products={initial} />
      </SearchIsland>
    </main>
  );
}
// app/api/search/route.ts: search runs on the server and returns at most 24 products.
import { search } from "../../../lib/products";

export function GET(request: Request) {
  const q = new URL(request.url).searchParams.get("q") ?? "";
  return Response.json(search(q, 24));
}

Measured effect: JavaScript transferred fell from 197 KB to 142 KB. The compressed difference looks small because a repetitive JSON catalog compresses well, but the browser still has to parse it: uncompressed script fell from 1,385 KB to 467 KB, and Lighthouse's total main-thread work fell from 2,524 ms to 823 ms once the widget was also handled (Fix 4).

Fix 2: Serve the LCP image with next/image, eager and high priority

The hero photo is the LCP element on both versions. As a plain img tag it downloads the full 2400-pixel JPEG on every device, starts late because nothing tells the browser it matters, and has no dimensions, so the layout jumps when it arrives.

import Image from "next/image";
import hero from "../public/hero.jpg"; // static import: Next.js reads the width and height at build time

<Image
  className="hero-img"
  src={hero}
  alt="Studio photo of camera gear on a desk"
  loading="eager"         // next/image lazy-loads by default; the LCP image must not wait
  fetchPriority="high"    // tells the browser this download comes first
  sizes="100vw"           // lets the browser pick a width that matches the screen
/>

With a static import, next/image knows the image's dimensions, generates resized AVIF and WebP versions, and serves the smallest one that fits. On Lighthouse's emulated phone it served a 750-pixel AVIF instead of the 1.3 MB JPEG, and the browser fetched it at High priority instead of Low.

Measured effect: the hero dropped from 1,331 KB to 7 KB. Our test photo is synthetic and compresses unusually well, so expect a smaller ratio on real photography; the direction is the same. In the Playwright run over slow 4G, the real LCP fell from 7.9 seconds to 0.8 seconds. Lighthouse's simulated LCP moved less (2.69 s to 1.89 s), a reminder to check the observed timings as well as the score.

If you are upgrading from Next.js 15 or earlier: the priority prop is deprecated in Next.js 16 in favour of preload, and the Next.js documentation recommends loading="eager" or fetchPriority="high" in most cases. Whichever you use, give it only to the LCP image. Several high-priority images compete for the same bandwidth and delay the one that matters.

Fix 3: Reserve space so nothing moves after load

The banner in /before is added by a useEffect 800 ms after the page loads, so the hero, heading and grid all jump down by its height. This is the same pattern as a cookie notice, an announcement bar or an ad slot that is filled late.

// Render the banner on the server, in the normal page flow, with its height reserved in CSS.
<div className="banner banner-slot">Free shipping on orders over $50 this week only!</div>

/* globals.css */
.banner-slot { min-height: 52px; }

If the content can only be known on the client, render an empty container with the same min-height on the server and fill it later. Combined with the dimensions from Fix 2, CLS fell from 0.075 to 0. The original value was already under the 0.1 threshold, but one late banner had used three-quarters of the budget, and one more late element would have failed the page.

Fix 4: Load third-party scripts on intent, not lazyOnload

This was the most instructive result. The widget in /before runs about 250 ms of work in the page body; on Lighthouse's mobile preset, which slows the CPU four times, that becomes a 1-second long task. The usual advice is to load it with next/script and strategy="lazyOnload", which waits until the page has loaded.

We measured that version separately. It made the Lighthouse score worse, 76 against 84 for /before. lazyOnload does not remove the work; it moves it after the first paint, which is exactly the window Total Blocking Time measures. In most /before runs the same long task ran before the first paint, where it delayed rendering but did not count towards TBT.

VersionThird-party widgetLighthouse scoreTotal Blocking Time
/beforeScript in the page body84 (range 70–85)385 ms
/after-lazyonloadnext/script, lazyOnload76 (range 67–77)1,135 ms
/afterFacade, loads on click97 (range 95–98)183 ms

The fix that worked is a facade: a lightweight button stands in for the widget, and the real script loads only when a visitor asks for it. Most visitors never open a chat window, so most visits never pay for it.

"use client";
import { useState } from "react";

export function ChatFacade() {
  const [loading, setLoading] = useState(false);
  const open = () => {
    if (loading) return;
    setLoading(true);
    const s = document.createElement("script");
    s.src = "/vendor/heavy-widget.js"; // the real third-party script
    s.async = true;
    document.body.appendChild(s);
  };
  return <button type="button" onClick={open}>{loading ? "Opening chat…" : "Chat with us"}</button>;
}

Use a facade for anything the visitor has to ask for, such as chat, video embeds and maps. For scripts that must run on every page, such as analytics, keep them small, load them with afterInteractive or lazyOnload, and measure the result; if one tag dominates TBT, that is a conversation with its vendor, not a Next.js setting.

Fix 5: Keep typing responsive (INP)

In /before, each keystroke filters 5,000 products and re-renders up to 500 cards, each creating its own Intl.NumberFormat, a surprisingly expensive object. Every key press waits for all of that before the next frame can paint.

"use client";
import { useEffect, useRef, useState, useTransition } from "react";

export function SearchIsland({ initial, children }) {
  const [query, setQuery] = useState("");
  const [results, setResults] = useState(null);
  const [, startTransition] = useTransition();
  const timer = useRef(undefined);

  useEffect(() => {
    clearTimeout(timer.current);
    if (!query.trim()) { setResults(null); return; }
    timer.current = setTimeout(async () => {
      const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
      const data = await res.json();
      startTransition(() => setResults(data)); // non-urgent: typing stays responsive
    }, 150);
    return () => clearTimeout(timer.current);
  }, [query]);

  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search 5,000 products" />
      {results ? <ProductGrid products={results} /> : children}
    </>
  );
}

// ProductGrid.tsx: one formatter for the whole module, not one per card.
const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });

Three changes do the work: the keystroke only updates the input, the search is debounced and runs on the server, and results arrive as a transition capped at 24 cards. Measured effect: the slowest keystroke while typing a six-letter search fell from a median of 232 ms (range 216–344 ms) to 16 ms in every run.

232 ms is already over the 200 ms good threshold on our emulated phone, and lower-end Android phones can be slower still. In an earlier run of the same page on a different runner the median was 176 ms, just inside the line, with one run at 264 ms: INP that sits near 200 ms in the lab will fail for part of your real visitors.

Results: before and after, measured

Metric/before/after-lazyonload/after
Lighthouse mobile score (median of 7)847697
LCP, Lighthouse simulated2.69 s2.11 s1.89 s
LCP, observed on slow 4G with 4x CPU slowdown7.92 s0.81 s0.77 s
Total Blocking Time385 ms1,135 ms183 ms
Cumulative Layout Shift0.07500
Slowest keystroke (INP proxy)232 ms16 ms16 ms
JavaScript transferred197 KB143 KB142 KB
JavaScript uncompressed1,385 KB467 KB467 KB
Hero image transferred1,331 KB7 KB7 KB

The /before score varied the most, from 70 to 85 across seven runs (and 72 to 93 in an earlier run on a different runner), because the widget's long task sometimes landed after the first paint and sometimes before it. The fixed page stayed between 95 and 98.

How we measured

  • Demo: Next.js 16.4.3 (Turbopack) and React 19.3.1, production build (next build, next start), on a GitHub Actions runner with 2 vCPUs and 7 GB of RAM.
  • Lighthouse 13.5.0, mobile preset (emulated mid-range phone, simulated slow 4G, 4x CPU slowdown), performance category only, seven runs per page, medians reported.
  • Playwright 1.64.1 with Chromium: Pixel 7 emulation, 4x CPU slowdown and slow-4G network emulation (1.6 Mbps down, 750 kbps up, 150 ms latency), seven runs per page. LCP and CLS come from the browser's PerformanceObserver; the INP proxy is the longest interaction recorded by the Event Timing API while typing "camera" one key at a time, three seconds after load.
  • The third-party widget is simulated: a script that keeps the main thread busy for 250 ms, which matches what we see from heavier chat and A/B-testing tags.
  • These are lab numbers from one page. Field data from real visitors (the Chrome UX Report, or the web-vitals library in your own analytics) is what Google uses for rankings, so confirm every fix there after release.

Common Next.js Core Web Vitals mistakes

  • Adding "use client" to a whole page or layout to make one component interactive.
  • Importing large data files or libraries into client components, where they are bundled and parsed on every visit.
  • Using a plain img tag for the hero, or leaving next/image's default lazy loading on the LCP image.
  • Giving many images high priority, so they compete with the one that is actually the LCP element.
  • Inserting banners, notices or embeds above existing content after load without reserved space.
  • Treating strategy="lazyOnload" as a fix for a heavy third-party script, without measuring Total Blocking Time afterwards.
  • Re-rendering long lists on every keystroke, and creating formatters or other expensive objects inside render.

Your Next.js Core Web Vitals checklist

  • Keep pages and layouts as Server Components; move interactivity into small client islands.
  • Check what each route ships: the network panel or a bundle analyzer should not show catalog data or unused libraries in client chunks.
  • Give the LCP image next/image with loading="eager", fetchPriority="high" and a correct sizes value, and give every other image dimensions.
  • Reserve space for anything that appears after the first paint.
  • Replace on-demand widgets (chat, video, maps) with facades; measure TBT for every remaining third-party tag.
  • Debounce search and filtering, render a capped result set, and use transitions for non-urgent updates.
  • Run Lighthouse at least five times per page and compare medians; a single run can swing by 15 to 20 points.
  • After release, watch field data in Search Console's Core Web Vitals report or your analytics before calling it fixed.

If your Next.js app is failing Core Web Vitals, our web performance optimisation service finds and fixes the causes, and our website speed optimisation work comes with regression testing so the fixes do not break features. For the metrics themselves and how Google uses them, read our Core Web Vitals in 2026 guide.

Frequently asked questions

Does next/image fix LCP on its own? — No. It resizes and compresses the image, but it lazy-loads by default, so the LCP image also needs loading="eager" and fetchPriority="high", and a sizes value that matches the layout. In our test, the plain img hero took 7.9 seconds on slow 4G and the fixed next/image version 0.8 seconds.

Is strategy="lazyOnload" good for third-party scripts? — It is good for small scripts that are not needed for the first view. It does not reduce the work a heavy script does: in our measurements it raised Total Blocking Time from 385 ms to 1,135 ms and lowered the Lighthouse score from 84 to 76. For widgets the visitor has to open, a facade that loads the script on click performed best.

Why is my Lighthouse score different on every run? — Lighthouse simulates a slow phone and network, and small timing differences can move long tasks before or after the first paint. Our original page ranged from 70 to 85 across seven runs. Run it five or more times and compare medians, and confirm with field data.

Do Server Components improve INP? — Indirectly. They reduce how much JavaScript the browser must download, parse and hydrate, which frees the main thread. INP itself depends on what each interaction does, so long lists, expensive renders and heavy event handlers still need fixing in the client components that remain.

Key takeaways

  • Fixing five common mistakes took a Next.js 16 page from 84 to 97 on Lighthouse mobile (medians of seven runs)
  • next/image with eager, high-priority loading cut the hero's real load time on slow 4G from 7.9 s to 0.8 s
  • strategy="lazyOnload" made a heavy third-party script worse: TBT rose from 385 ms to 1,135 ms; a click-to-load facade fixed it
  • Keeping the 1 MB catalog on the server cut uncompressed JavaScript from 1,385 KB to 467 KB
  • Debounced server search with a 24-result cap took the slowest keystroke from 232 ms to 16 ms
  • Lighthouse varies run to run (70 to 85 on one page): compare medians and confirm with field data

Free website review

Want a developer to review your website?

Send us your website's address. A developer will check its speed, mobile layout, broken links and forms, and email you the most important fixes, in order of impact. Free, and no call needed.

Related service

Web Development →

Fast, accessible web apps backed by resilient APIs.

Further reading

About the author

Kawsher, Founder, QA Labs →

Kawsher is the founder of Quality Assurance Labs (QA Labs), the QA-first software testing and development company in Bogura, Bangladesh, founded in 2021. Every article and case study on this site is written or reviewed by Kawsher, drawing on the team's QA testing, AI, web, mobile, video and marketing work for SaaS and AI teams worldwide.

Notes from the lab.

Testing, engineering and growth — delivered to your inbox.

Is your Next.js app failing Core Web Vitals? Book a scoping call

Let's talk →