All articles
Engineering#next.js#performance#core web vitals#react

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.

FX

Codestria Engineering

Engineering team

7 min read

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.

app/products/[id]/page.tsx
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/image so you get responsive srcset, modern formats and correct dimensions.
  • Mark the above-the-fold hero with preload (in Next.js 16 this replaces the deprecated priority prop) so it is not lazy-loaded and is requested early.
  • Provide an accurate sizes attribute. 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.
components/hero.tsx
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/image does 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 transform and opacity, never top, height or margin.
app/layout.tsx
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.

  1. Ship less JavaScript. Audit your client bundles. Every "use client" boundary pulls its imports into the browser.
  2. Lazy-load heavy widgets such as charts, editors and maps with next/dynamic so they do not block hydration of the rest of the page.
  3. Mark non-urgent updates as transitions. useTransition and useDeferredValue let React keep the input responsive while expensive results render.
  4. Break up long tasks. Split heavy work into chunks and yield to the browser between them, or move it to a Web Worker.
  5. Load third-party scripts deliberately with next/script and the lazyOnload or afterInteractive strategy.
components/search.tsx
"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.

app/web-vitals.tsx
"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 cache for shared data and Suspense with fixed-height fallbacks for dynamic parts.
  • Make the LCP image discoverable early with next/image, preload and accurate sizes.
  • Reserve space for everything and load fonts with next/font to 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.

Found this useful? Share it with your team.

LinkedInX

Need help with frontend engineering?

Fast, accessible web apps, dashboards and marketing sites with React, Next.js and Vue.

Learn more