← Blog
25 June 2026 · Jack Williams

How to Track Core Web Vitals in Next.js

Measure LCP, INP and CLS from real users in a Next.js app: why field data beats lab scores, the code to collect it, and how to read the p75 thresholds Google ranks on.

If you want to know how fast your Next.js app feels to real people, Core Web Vitals are the numbers to watch. This guide shows you how to collect them from real visitors, where to send them, and how to read the results. The collection code is a few lines, and the same approach works in any framework once you understand the pieces.

What Core Web Vitals actually measure

Core Web Vitals are three metrics Google uses to score real-world experience, and they feed into search ranking:

  • LCP (Largest Contentful Paint) is loading. It marks when the biggest element in the viewport, usually a hero image or heading, finishes rendering. Good is under 2.5 seconds.
  • INP (Interaction to Next Paint) is responsiveness. It measures the delay between a user interacting and the screen updating, across the whole visit. Good is under 200 milliseconds. INP replaced FID in 2024, so older tutorials that mention FID are out of date.
  • CLS (Cumulative Layout Shift) is visual stability. It captures how much the layout jumps around as the page loads. Good is under 0.1.

Two supporting metrics, FCP (First Contentful Paint) and TTFB (Time to First Byte), are worth collecting too because they help explain a bad LCP.

Field data beats your Lighthouse score

The single most important thing to understand: a Lighthouse score from your laptop is a lab measurement, run once, on one fast machine, on one network. Your actual users are on mid-range phones, flaky connections and cold caches. That is field data, and it is what Google ranks on.

A site can score 100 in Lighthouse and still have poor field vitals, because real INP only happens when real people click things. So the goal here is to measure the metrics in the browsers of actual visitors and send them somewhere you can read them.

Collecting the metrics in Next.js

Next.js ships a hook for exactly this in the App Router. Create a small client component:

// 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, // 'good' | 'needs-improvement' | 'poor'
      path: window.location.pathname,
    })

    // sendBeacon survives the page unloading; fetch is the fallback.
    const url = '/api/vitals'
    if (navigator.sendBeacon) navigator.sendBeacon(url, body)
    else fetch(url, { body, method: 'POST', keepalive: true })
  })

  return null
}

Then drop it into your root layout so it runs on every route:

// app/layout.tsx
import { WebVitals } from './web-vitals'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <WebVitals />
        {children}
      </body>
    </html>
  )
}

That is the whole client side. The hook fires each metric as soon as the browser can report it, which for INP and CLS is usually when the user leaves the page.

The framework-agnostic version

If you are not on Next.js, the underlying tool is Google's web-vitals library, and the pattern is identical:

import { onLCP, onINP, onCLS, onFCP, onTTFB } from 'web-vitals'

function send(metric) {
  navigator.sendBeacon('/api/vitals', JSON.stringify(metric))
}

onLCP(send)
onINP(send)
onCLS(send)
onFCP(send)
onTTFB(send)

Receiving the data

On the server you just need an endpoint that accepts the beacon and stores it. A minimal Next.js route handler:

// app/api/vitals/route.ts
export async function POST(req: Request) {
  const metric = await req.json()
  // Store it: a database, a time-series table, your logging pipeline.
  console.log('[vitals]', metric.name, Math.round(metric.value), metric.path)
  return new Response(null, { status: 204 })
}

In production you would write these to something you can query later, ideally a columnar store, because vitals are high-volume and you will want to aggregate them by page and by day.

Reading the numbers: aggregate at p75

Here is the part most people get wrong. Do not look at averages. One slow visitor on a train tunnel will not drag an average much, but it is exactly the experience you want to catch. Google evaluates the 75th percentile (p75): the value that 75% of your visits come in under.

So for each metric, on each page, you want the p75 over a rolling window, usually the last 28 days. A page passes when its p75 LCP is under 2.5s, p75 INP under 200ms and p75 CLS under 0.1. Track those three per route and you know exactly which pages need work, and whether a deploy made things better or worse.

The shortcut

The code above is the honest, build-it-yourself version, and it is worth understanding. But maintaining the collection script, the ingestion endpoint, the storage, and the p75 aggregation per page is a project in itself.

This is what JAMP does out of the box. You add one tiny script, no application code, and it collects Core Web Vitals from your real visitors, rolls them up to p75 per page, and tracks them over time so you can see the effect of each release. It also runs on-demand lab audits when you want a Lighthouse-style breakdown, so you get the field numbers and the lab diagnosis in one place. You can see it on live data in the demo dashboard, and the vitals docs cover the setup.

However you collect them, the principle is the same: measure real users, aggregate at p75, and watch the trend per page. That is what turns "the site feels slow" into a number you can actually fix.