Fixing Core Web Vitals in the Next.js App Router
A hands-on guide to fixing LCP, INP and CLS in the Next.js App Router — client boundaries, next/image, next/font, Suspense streaming and a Lighthouse CI budget.
- Performance
- Core Web Vitals
- Next.js
- Frontend
- Web Platform
Most Core Web Vitals advice is written as a checklist, which is why it doesn't work. You apply eleven generic tips, the score moves two points, and nobody can say which tip did it. The App Router makes this worse in one specific way: the framework gives you enough server rendering by default that teams assume performance is handled, then quietly undo it with a "use client" at the top of the layout.
This post is the opposite of a checklist. It goes metric by metric — LCP, INP, CLS — and for each one names the mechanism, the App Router-specific thing that breaks it, and the code that fixes it. The thresholds worth memorising: LCP ≤ 2.5s, INP ≤ 200ms, CLS ≤ 0.1, measured at the 75th percentile over a 28-day window in field data. INP replaced FID as a Core Web Vital in March 2024, which matters more than it sounds — FID was easy to pass by accident, and INP is not.
LCP: it's almost always the critical path, not the image
Largest Contentful Paint measures when the biggest above-the-fold element finishes rendering. Step one is not optimisation, it's identification: open DevTools, run a Performance trace, and look at the LCP marker — it tells you the exact element. Teams routinely spend a week compressing a hero image that isn't the LCP element at all, because the real one is an <h1> blocked on a web font.
Once you know the element, the question is what's on its critical path. There are usually only four candidates: the HTML arriving late, the image discovered late, the font blocking the text, or JavaScript standing between the server response and first paint.
If the LCP element is an image, next/image with priority is the single highest-leverage change. It sets fetchpriority="high", opts the image out of lazy loading, and emits a preload hint so the browser starts fetching before it parses that far down the DOM.
import Image from "next/image";
export default function Hero() {
return (
<Image
src="/hero.jpg"
alt="Product dashboard"
width={1440}
height={720}
priority
sizes="(max-width: 768px) 100vw, 1440px"
/>
);
}
sizes is the part people skip, and skipping it costs more than the priority gains. Without an accurate sizes, the browser assumes the image occupies the full viewport width and picks the largest source in the srcset — so a phone downloads a 1440px asset to render it at 390px. Get sizes wrong and you have made the LCP worse while feeling productive.
Fonts are the other half. Use next/font — it self-hosts the font files at build time, so there's no third-party connection to negotiate before text can paint, and it applies font-display: swap by default so text renders immediately in a fallback rather than sitting invisible (FOIT) while the webfont downloads.
import { Inter } from "next/font/google";
export const inter = Inter({
subsets: ["latin"],
display: "swap",
preload: true,
fallback: ["system-ui", "arial"],
});
Then JavaScript. This is the App Router-specific trap. A client component boundary high in the tree means the server still streams HTML, but that HTML is inert until React hydrates it — and if your hero lives inside a provider chain that's marked "use client", the whole subtree ships as a client bundle and its paint waits behind download, parse and hydrate. The framework didn't fail you; the boundary placement did.
Worse is dynamic(..., { ssr: false }) on anything above the fold. That's an explicit instruction to render nothing on the server, ship the JS, execute it, and only then paint. For a hero, it converts an LCP that could have been server-rendered in the first byte into one gated on the entire client runtime. It's a legitimate escape hatch for genuinely browser-only widgets far down the page. It is never right for hero content.
Suspense is the tool for the common case where one slow query holds up an otherwise fast page. Wrap the slow region, give it a real placeholder, and the shell — including your LCP element — streams and paints immediately.
import { Suspense } from "react";
export default async function Page({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
return (
<>
<Hero slug={slug} />
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews slug={slug} />
</Suspense>
</>
);
}
Note await params — in Next 15 the dynamic APIs are async, and awaiting them at the top of a server component is the correct shape.
INP: you don't have an interaction problem, you have a long-task problem
Interaction to Next Paint measures the full latency from a user's input to the next frame the browser paints. When it's bad, the cause is nearly always the same: the main thread is busy with a long task, so your handler can't start, or it starts and can't finish before the next frame.
There are four reliable sources. Oversized client bundles that take hundreds of milliseconds to parse and execute. Hydration of components that never needed to be client components. Event handlers doing real work synchronously. And third-party scripts — analytics, chat widgets, tag managers — executing whenever they feel like it.
The structural fix is to push "use client" down the tree. Interactivity is usually a leaf: one button, one input, one dropdown. If the boundary sits at the page or layout, every descendant becomes part of the client bundle even though nine tenths of it is static markup.
// app/products/page.tsx — server component, no client JS
import { AddToCart } from "./add-to-cart";
export default async function Page() {
const products = await getProducts();
return products.map((p) => (
<article key={p.id}>
<h2>{p.name}</h2>
<AddToCart id={p.id} /> {/* only this leaf is "use client" */}
</article>
));
}
This is the change we reach for first on any App Router codebase with a bad INP, because it usually removes more JavaScript than every other optimisation combined.
Third-party scripts get next/script with a deliberate strategy. afterInteractive runs the script after the page becomes interactive — correct for things you actually need early, like a consent manager or analytics you care about. lazyOnload waits until the browser is idle — correct for chat widgets, heatmaps, and anything marketing added without asking.
import Script from "next/script";
<Script src="https://widget.example.com/chat.js" strategy="lazyOnload" />;
For your own state updates, startTransition tells React which renders are allowed to be interrupted. A search field that filters a large list is the canonical case: the input value must update urgently, the filtered results must not.
"use client";
import { useState, useTransition } from "react";
export function Filter() {
const [query, setQuery] = useState("");
const [filter, setFilter] = useState("");
const [, startTransition] = useTransition();
return (
<input
value={query}
onChange={(e) => {
setQuery(e.target.value); // urgent: the input must feel instant
startTransition(() => setFilter(e.target.value)); // interruptible
}}
/>
);
}
Finally, watch for layout thrashing — reading a geometry property like offsetHeight after a write forces a synchronous style and layout recalculation, and doing that in a loop inside a scroll or resize handler will destroy INP on its own. Batch your reads, then your writes, and keep both out of high-frequency handlers.
CLS: reserve space for everything, always
Cumulative Layout Shift is the easiest metric to fix and the most commonly ignored, because it doesn't show up in local development where everything is cached and instant. The rule is singular: anything that will occupy space later must occupy that space from the first paint.
Images need intrinsic dimensions. next/image requires width/height (or fill with a positioned parent) precisely so it can reserve the box before the bytes arrive. For images you don't control, aspect-ratio in CSS does the same job.
Ads, embeds, and banners are the usual culprits. A third-party iframe that resolves 800ms in and pushes your content down is a large shift, and it's entirely preventable with a fixed-height container.
<div style={{ minHeight: 250 }} className="ad-slot">
<AdUnit slot="leaderboard" />
</div>
Fonts cause a subtler version. When the fallback font has different metrics than the webfont, the swap reflows every line of text. next/font handles this by generating a fallback @font-face with size-adjust, ascent-override and descent-override computed to match the real font's metrics — so the swap is close to invisible. For local fonts, adjustFontFallback controls the same behaviour.
Cookie banners deserve a specific mention because they're often injected by a script the frontend team doesn't own. If it renders at the top of the document and pushes the page down, it's a shift on every first visit — which is every user in the field data. Either overlay it with position: fixed so it's outside the layout flow entirely, or reserve its height server-side.
And animate transform and opacity, never width, height, top or margin. The former run on the compositor and don't shift anything; the latter trigger layout and count against CLS.
Measuring: lab for diagnosis, field for truth
Lab tools tell you why. Field data tells you whether it matters. You need both, and confusing them is how teams end up optimising a page nobody visits on a device nobody owns.
Start with the build output. next build prints per-route First Load JS, which is the fastest signal that a client boundary has slipped somewhere it shouldn't be — a route that jumps from 90kB to 300kB between PRs is telling you exactly that. When you need to know what's in there, @next/bundle-analyzer gives you a treemap.
npm install --save-dev @next/bundle-analyzer
ANALYZE=true npm run build
The DevTools Performance panel is where you diagnose INP: record, interact, and look for the long tasks — anything over 50ms — sitting between input and paint. It will attribute them to specific scripts and functions, which is the difference between "the bundle is too big" and "this one date library is costing 180ms."
For field data, the web-vitals package reports real user metrics from real devices. In the App Router, wire it up in a small client component mounted in the root layout.
"use client";
import { useReportWebVitals } from "next/web-vitals";
export function Vitals() {
useReportWebVitals((metric) => {
navigator.sendBeacon("/api/vitals", JSON.stringify(metric));
});
return null;
}
sendBeacon is the right transport here — it survives the page unload that would abort a fetch. Store the values, chart the 75th percentile, and segment by route and device class. The aggregate number hides the fact that one template is dragging everything down.
Make it a gate, not a report
Performance work that isn't enforced regresses within a quarter. Someone adds a carousel, someone else adds a tag manager, and six months of work evaporates without a single person noticing until a user complains.
The fix is a budget in CI. Lighthouse CI runs against a preview deploy and fails the build when a metric or a bundle size crosses the line you set.
{
"ci": {
"assert": {
"assertions": {
"largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
"cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
"total-blocking-time": ["error", { "maxNumericValue": 200 }]
}
}
}
}
Total Blocking Time stands in for INP in the lab, since INP needs real interactions to measure. It correlates well enough to catch the regressions that matter.
Set the initial thresholds at your current numbers, not your target numbers. A budget that fails on day one gets disabled on day two. Ratchet it down as you improve.
TL;DR
LCP is a critical-path problem. Identify the actual element first, then give it priority and a correct sizes if it's an image, self-host fonts with next/font and display: swap, keep client boundaries below the fold, stream slow regions behind Suspense, and never put ssr: false on hero content.
INP is a long-task problem. Push "use client" down to interactive leaves so you hydrate less, give third-party scripts an explicit next/script strategy, wrap non-urgent updates in startTransition, and keep layout reads out of high-frequency handlers.
CLS is a reservation problem. Dimensions on every image, fixed containers for ads and embeds, metric-matched font fallbacks, overlaid cookie banners, and animations restricted to transform and opacity.
Then measure both ways — next build and the bundle analyzer for what you ship, DevTools for why it's slow, web-vitals at p75 for whether users actually feel it — and put a Lighthouse CI budget in front of merge so none of it silently comes back.
If you're staring at a Core Web Vitals report and can't tell which fix to make first, we'd be glad to take a look.
