A Next.js performance checklist: Core Web Vitals in practice
A practical, code-first checklist for improving LCP, INP and CLS in Next.js App Router projects, from rendering strategy to images, fonts, scripts and field monitoring.
Codestria Engineering
Engineering team
Core Web Vitals are Google's user-centred performance metrics, and they double as a reasonable definition of feels fast. There are three: Largest Contentful Paint (LCP) measures loading, Interaction to Next Paint (INP) measures responsiveness, and Cumulative Layout Shift (CLS) measures visual stability. Google's published thresholds for a good experience are an LCP of 2.5 seconds or less, INP of 200 milliseconds or less, and CLS of 0.1 or less, measured at the 75th percentile of real page loads.
Next.js gives you excellent defaults, but defaults do not survive contact with a real product: a hero image added without sizing, a chat widget loaded in the head, a client component that wraps the whole page. This checklist is what we run through on App Router projects, roughly in order of impact.
1. Render as much as possible on the server, ahead of time
The fastest page is static HTML served from a CDN. In the App Router, Server Components are the default and ship no JavaScript for their own rendering. Your first job is to keep it that way: push "use client" down to the smallest interactive leaves instead of marking whole layouts as client components.
With Cache Components enabled in Next.js 16, the model becomes explicit. Anything not wrapped in <Suspense> and not reading request data is prerendered into a static shell. Data that can be shared between users is cached with the use cache directive, and genuinely dynamic parts stream in behind a Suspense boundary.
import { Suspense } from "react";
import { cacheLife } from "next/cache";
import { getProduct, getStock } from "@/lib/data";
async function ProductDetails({ id }: { id: string }) {
"use cache";
cacheLife("hours");
const product = await getProduct(id);
return (
<section>
<h1>{product.name}</h1>
<p>{product.description}</p>
</section>
);
}
async function StockBadge({ id }: { id: string }) {
// Not cached: always fresh, streamed after the static shell.
const stock = await getStock(id);
return <p>{stock > 0 ? `${stock} in stock` : "Out of stock"}</p>;
}
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return (
<main>
<ProductDetails id={id} />
<Suspense fallback={<p className="h-6">Checking stock…</p>}>
<StockBadge id={id} />
</Suspense>
</main>
);
}Note the fallback has a fixed height. A Suspense fallback that is shorter than the content it is replaced by is one of the most common sources of layout shift in streamed pages.
2. Make the LCP element load first
On most marketing and product pages the LCP element is a hero image or a large heading. For text, LCP mostly depends on server response time and fonts. For images, it depends on how early the browser discovers the file and how heavy it is.
- Use
next/imageso you get responsivesrcset, modern formats and correct dimensions. - Mark the above-the-fold hero with
preload(in Next.js 16 this replaces the deprecatedpriorityprop) so it is not lazy-loaded and is requested early. - Provide an accurate
sizesattribute. Without it, a full-width image may download the largest variant on a phone. - Do not lazy-load or fade-in the LCP image with JavaScript; it delays the paint the metric is measuring.
- Avoid CSS background images for hero content: the browser discovers them late.
import Image from "next/image";
import heroImage from "@/public/hero.jpg"; // static import gives width, height and blur data
export function Hero() {
return (
<section className="relative">
<Image
src={heroImage}
alt="Dashboard showing order analytics"
preload
placeholder="blur"
sizes="(min-width: 1200px) 1200px, 100vw"
className="h-auto w-full rounded-2xl"
/>
<h1 className="mt-8 text-5xl font-semibold">Ship faster with confidence</h1>
</section>
);
}3. Eliminate layout shift
CLS is usually the easiest vital to fix because the causes are mechanical. Anything that appears or changes size after first paint pushes content around.
- Images and video: always reserve space with width and height or an
aspect-ratio.next/imagedoes this when it knows the dimensions. - Fonts: load them with
next/font, which self-hosts the files and generates fallback metrics so the swap from fallback to web font does not reflow text. - Embeds and ads: wrap them in a container with a fixed minimum height.
- Banners and cookie notices: overlay them rather than inserting them above content.
- Animations: animate
transformandopacity, nevertop,heightormargin.
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const sans = Geist({ subsets: ["latin"], variable: "--font-sans", display: "swap" });
const mono = Geist_Mono({ subsets: ["latin"], variable: "--font-mono", display: "swap" });
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={`${sans.variable} ${mono.variable}`}>
<body>{children}</body>
</html>
);
}4. Keep the main thread free for interactions
INP measures the delay between a user interaction and the next frame being painted. Poor INP almost always means the main thread is busy: hydrating too much JavaScript, running a large render on every keystroke, or executing third-party scripts.
- Ship less JavaScript. Audit your client bundles. Every
"use client"boundary pulls its imports into the browser. - Lazy-load heavy widgets such as charts, editors and maps with
next/dynamicso they do not block hydration of the rest of the page. - Mark non-urgent updates as transitions.
useTransitionanduseDeferredValuelet React keep the input responsive while expensive results render. - Break up long tasks. Split heavy work into chunks and yield to the browser between them, or move it to a Web Worker.
- Load third-party scripts deliberately with
next/scriptand thelazyOnloadorafterInteractivestrategy.
"use client";
import { useDeferredValue, useState } from "react";
import dynamic from "next/dynamic";
import { ResultsList } from "./results-list";
// The chart library only loads when this component renders, and never on the server.
const SalesChart = dynamic(() => import("./sales-chart"), {
ssr: false,
loading: () => <div className="h-64 rounded-xl bg-surface" />,
});
export function Search({ items }: { items: { id: string; name: string }[] }) {
const [query, setQuery] = useState("");
// The input updates immediately; filtering the list can lag behind without blocking typing.
const deferredQuery = useDeferredValue(query);
const results = items.filter((item) =>
item.name.toLowerCase().includes(deferredQuery.toLowerCase()),
);
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} aria-label="Search" />
<ResultsList results={results} />
<SalesChart />
</div>
);
}5. Tame third-party scripts
Analytics, tag managers, chat widgets and A/B testing tools are frequently the single largest contributor to poor INP on otherwise well-built sites. Treat each one as a cost. Ask whether it is still used, load it with the least aggressive next/script strategy that works, and prefer server-side or lightweight alternatives where they exist. A chat widget, for example, can render a static button and only load the full SDK when someone clicks it.
6. Measure in the field, not just the lab
Lighthouse runs on a single simulated device and cannot measure INP, because there is no real user interacting. It is useful for catching regressions, but the numbers that matter come from real users. Collect them with useReportWebVitals and send them to your analytics backend, then watch the 75th percentile by route and device type.
"use client";
import { useReportWebVitals } from "next/web-vitals";
export function WebVitals() {
useReportWebVitals((metric) => {
const body = JSON.stringify({
name: metric.name, // LCP, INP, CLS, FCP, TTFB
value: metric.value,
rating: metric.rating,
id: metric.id,
page: window.location.pathname,
});
// sendBeacon survives page unloads; fall back to keepalive fetch.
if (!navigator.sendBeacon?.("/api/vitals", body)) {
fetch("/api/vitals", { method: "POST", body, keepalive: true });
}
});
return null;
}Render <WebVitals /> once in the root layout. Combine this with the Chrome UX Report data in Search Console for a second, independent view of the same metrics.
Field data also tells you where to look. LCP problems usually cluster on specific templates, such as product pages with large galleries. INP problems tend to come from specific interactions, such as opening a filter drawer or typing into a search box. Recording the route and, where possible, the element involved turns a vague the site feels slow into a short list of concrete fixes.
Finally, protect the gains. Performance tends to regress one small change at a time: a new dependency here, an unoptimised image there. A bundle size check in CI and a Lighthouse run against key pages on every pull request will not catch everything, but they flag the obvious regressions before they ship, while the change is still fresh in the author's mind.
Key takeaways
- Default to Server Components and static prerendering; push client boundaries to the leaves.
- Use
use cachefor shared data and Suspense with fixed-height fallbacks for dynamic parts. - Make the LCP image discoverable early with
next/image,preloadand accuratesizes. - Reserve space for everything and load fonts with
next/fontto keep CLS near zero. - Protect INP by shipping less JavaScript, deferring heavy widgets and auditing third-party scripts.
- Trust field data at the 75th percentile over a single Lighthouse run.
If your Next.js app is slower than it should be, our frontend engineering team can audit it and fix the root causes. Tell us about your project.
Need help with frontend engineering?
Fast, accessible web apps, dashboards and marketing sites with React, Next.js and Vue.