Skip to content

Core Web Vitals in Next.js: What Actually Moves the Numbers

LCP, INP and CLS have specific causes in a Next.js application. This is the ordered list we work through on performance engagements, with the changes that pay and the ones that do not.

4 min read

A Lighthouse score is a lab measurement of one page load on one simulated device. Field data is what your visitors actually experienced, and it is what Google uses. Start there, always:

# p75 field data for an origin, from the Chrome UX Report
curl -s "https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=$CRUX_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"origin":"https://example.com","formFactor":"PHONE"}'

If your lab score is 95 and your field LCP is 3.8s, the lab is lying to you about something — usually a font, a third-party script, or a real-world network your simulated one is not modelling.

LCP: it is almost always the image or the font

Largest Contentful Paint measures when the biggest above-the-fold element finishes painting. In a Next.js application, four causes cover the large majority of failures.

1. The hero image is not prioritised

import Image from 'next/image';
 
<Image
  src="/hero.jpg"
  alt=""
  width={1200}
  height={630}
  priority          // preloads it; without this it waits in the lazy queue
  sizes="100vw"
  quality={80}
/>

Without priority, your LCP element sits behind every other resource in the browser's queue. This is a one-line change that routinely takes half a second off mobile LCP.

Equally: priority on more than one or two images is the same mistake in reverse — everything preloaded means nothing prioritised.

2. The route is dynamic when it did not need to be

A statically rendered route serves HTML from the nearest edge node. A dynamic one runs your component tree and your database first. If time to first byte is above 600ms, no amount of image optimisation will rescue LCP — the browser has not received anything to paint yet.

This is the connection between rendering strategy and Core Web Vitals that most performance checklists miss entirely.

3. Fonts block the paint

import { Inter } from 'next/font/google';
 
const inter = Inter({
  subsets: ['latin'],
  display: 'swap',      // paint immediately with the fallback
  preload: true,
  adjustFontFallback: true,  // reduces the shift when the real font arrives
});

next/font self-hosts the file, which removes a DNS lookup, a TLS handshake and a round trip to a third-party origin from your critical path. If you are still loading fonts from a <link> to a font CDN, that is a free improvement sitting on the table.

4. A third-party script is in the head

Tag managers, chat widgets, consent banners, session recorders. Each one is a blocking request on the critical path unless it is explicitly deferred:

import Script from 'next/script';
 
<Script src="https://example.com/widget.js" strategy="lazyOnload" />

In audits, the single largest LCP contributor is a third-party script about a third of the time. It is worth measuring before you spend a sprint on your own code.

INP: the cost of hydration

Interaction to Next Paint replaced First Input Delay because FID measured the wrong thing — it timed only the delay before the handler started, not how long the visitor waited to see a result.

INP problems in Next.js are bundle problems. The causes, in the order we find them:

  • A client boundary too high in the tree. Covered in depth in our article on where use client actually costs you.
  • A heavy dependency imported at module scope. Charting libraries, date libraries, editors. Load them with next/dynamic and ssr: false when the component is genuinely below the fold.
  • Expensive work in an event handler. Filtering ten thousand rows in an onChange. Move it to the server, or debounce and virtualise.
  • Too many hydration roots. A hundred independently interactive cards on a listing page is a hundred hydration units competing for the main thread.
import dynamic from 'next/dynamic';
 
const Chart = dynamic(() => import('@/components/Chart'), {
  ssr: false,
  loading: () => <ChartSkeleton />,
});

CLS: reserve the space

Cumulative Layout Shift is the easiest of the three to fix and the most embarrassing to ship.

  • Always give images explicit width and height, or wrap them in a container with a fixed aspect-ratio. next/image enforces this, which is one of the better reasons to use it.
  • Reserve space for anything that arrives late — ad slots, embeds, consent banners. A skeleton with the final element's dimensions costs nothing and removes the shift entirely.
  • Use font-display: swap together with adjustFontFallback so the fallback metrics approximate the real font.
  • Never inject a banner above existing content after load. Overlay it, or reserve the space in the initial HTML.

Defending the result

Every fix above decays. Someone adds a dependency, someone adds a script, someone moves a boundary. Without an enforcement mechanism you will do this work again next year.

# .github/workflows/performance.yml
- name: Lighthouse CI
  uses: treosh/lighthouse-ci-action@v12
  with:
    urls: |
      https://staging.example.com/
      https://staging.example.com/pricing
    budgetPath: ./lighthouse-budget.json
    uploadArtifacts: true
[
  {
    "path": "/*",
    "resourceSizes": [
      { "resourceType": "script", "budget": 180 },
      { "resourceType": "total", "budget": 600 }
    ],
    "timings": [{ "metric": "interactive", "budget": 3000 }]
  }
]

A pull request that pushes the JavaScript budget past 180KB now fails a check instead of quietly shipping. That budget file is the actual deliverable of a performance engagement. The improved score is just what happens on the way to having one.

The order we work in

  1. Get field data. Segment by device class and route template.
  2. Fix rendering strategy — a dynamic route that should be static outranks everything else on this list.
  3. Fix the LCP element: priority, dimensions, format.
  4. Move fonts to next/font, third-party scripts off the critical path.
  5. Pull the client boundary down; code-split what is left.
  6. Reserve space for everything that arrives late.
  7. Put a budget in CI so none of the above can regress.

Steps two and three are usually most of the win. The rest is keeping it.

Back to all articles