Next.js Web Vitals Monitoring with useReportWebVitals: LCP, INP, CLS, and Real User Data (2026)
How to wire up useReportWebVitals in the Next.js App Router, ship LCP, INP, and CLS from real users to any analytics endpoint, and debug the metrics that hurt Search rankings, with working code.
Next.js Web Vitals monitoring uses the useReportWebVitals hook from next/web-vitals to capture LCP, INP, CLS, TTFB, and FCP from real users' browsers and ship them to your analytics endpoint. Drop the hook into a small Client Component, mount it inside the root app/layout.tsx, and every navigation fires a metric event with the p75 numbers Google actually uses to score your Core Web Vitals. In my experience the whole setup is a fifteen-minute PR, and honestly, it's the fastest way to catch regressions before Search Console emails you about them.
useReportWebVitals is a thin Next.js wrapper around Google's web-vitals library and reports LCP, INP, CLS, TTFB, FCP, and Next.js-specific timings like Next.js-hydration and Next.js-route-change-to-render.
INP replaced FID as a Core Web Vital in March 2024. If your monitoring code still reads metric.name === 'FID', it has been silently dead for two years.
The hook must live in a Client Component ('use client') and should be mounted once, near the root of the App Router tree, not per page.
Ship values with navigator.sendBeacon or fetch(..., {keepalive: true}) so the request survives page unloads. Regular fetch loses metrics on hard navigation.
Look at p75, not averages. One slow user on cellular can double a mean, and Google's CrUX only cares about the 75th percentile.
Vercel Analytics, PostHog, Datadog RUM, and Google Analytics 4 all accept the same shape. The hook is the plumbing, not the vendor lock-in.
What is useReportWebVitals in Next.js?
useReportWebVitals is a React hook exported from next/web-vitals that receives a metric object every time the browser has a new performance measurement to report. Under the hood it subscribes to the same PerformanceObserver APIs that Google's web-vitals library uses, plus a couple of Next.js-specific events (Next.js-hydration, Next.js-route-change-to-render, Next.js-render). The hook exists because the App Router unmounts and remounts a lot of the tree on navigation, and calling onLCP / onINP / onCLS yourself can double-subscribe if you're not careful with dependency arrays.
The metric object looks like { id, name, label, value, delta, rating }. name is the metric key (LCP, INP, CLS, FCP, TTFB), value is the current measurement, delta is the change since the last report (INP and CLS can update multiple times per page load), and rating is Google's bucketing: good, needs-improvement, or poor. Aggregating on the id field on the receiving end deduplicates so you don't double-count a metric that fires twice.
Install and mount the hook in App Router
There's nothing to install. next/web-vitals ships with Next.js 13+ (App Router). Create a Client Component, register the hook, and drop it into app/layout.tsx. Because layouts are Server Components by default, the reporter has to live in its own 'use client' file, since the callback runs in the browser.
// app/_components/web-vitals.tsx
'use client';
import { useReportWebVitals } from 'next/web-vitals';
export function WebVitals() {
useReportWebVitals((metric) => {
// metric: { id, name, value, delta, rating, navigationType }
const body = JSON.stringify({
...metric,
url: window.location.pathname,
// include a stable page identifier for aggregation
route: window.__NEXT_DATA__?.page ?? window.location.pathname,
});
// sendBeacon survives page unload; fetch does not
if (navigator.sendBeacon) {
navigator.sendBeacon('/api/vitals', body);
} else {
fetch('/api/vitals', {
body,
method: 'POST',
keepalive: true,
headers: { 'Content-Type': 'application/json' },
});
}
});
return null;
}
That's it. The component renders null, adds zero pixels to the DOM, and starts firing metrics on the very next navigation. Mount it in the root layout, not per-route. The App Router preserves the root layout across navigations, which is exactly what you want for a subscription that should live for the tab's lifetime.
Which metrics matter in 2026: LCP, INP, CLS
Google's current Core Web Vitals are LCP, INP, and CLS. FID was retired in March 2024, and you can read the announcement on web.dev's INP transition post. If your dashboards still track FID, they are showing you a metric that no longer affects Search rankings.
Here's what each metric actually measures and what "good" means at the p75 percentile that Google uses:
Metric
What it measures
Good (p75)
Poor (p75)
Common Next.js cause when bad
LCP
Time to render the largest visible element (usually a hero image or headline block)
≤ 2.5s
> 4.0s
Non-optimized hero image, blocking third-party scripts, waterfall in Server Component fetches
INP
Interaction to Next Paint, the slowest interaction on the page (click, tap, key)
Cumulative Layout Shift, total visual instability over the page's lifetime
≤ 0.1
> 0.25
Missing width/height on images, web fonts without next/font, streaming Suspense boundaries with no reserved space
TTFB
Time to First Byte, how long the edge/origin takes to start responding
≤ 800ms
> 1.8s
Uncached dynamic rendering, cold serverless starts, blocking data fetches at layout level
FCP
First Contentful Paint, when the first pixel of content lands
≤ 1.8s
> 3.0s
Same as LCP causes plus large above-the-fold JS
TTFB and FCP aren't Core Web Vitals in the ranking sense, but INP is highly correlated with hydration cost and TTFB is highly correlated with your server strategy, so you want them both on the same dashboard.
How do I measure INP in Next.js?
INP is the metric most teams get wrong because it's the one that fires throughout the session, not once. Every interaction (every click, tap, and non-composition keystroke) produces a candidate. INP is the worst of those, roughly the 98th percentile once you have more than 50 interactions. Which means your endpoint will receive many INP updates for a single page, and you need to keep only the highest per id.
The hook handles the observation part. On the server, dedupe like this:
// app/api/vitals/route.ts
import { NextResponse } from 'next/server';
type Vital = {
id: string;
name: 'LCP' | 'INP' | 'CLS' | 'FCP' | 'TTFB';
value: number;
delta: number;
rating: 'good' | 'needs-improvement' | 'poor';
route: string;
};
export async function POST(req: Request) {
const body = (await req.json()) as Vital;
// Persist - upsert by id and take the max value for INP/CLS
// (they update over the page's life; LCP/FCP/TTFB report once)
await db.insert({
id: body.id,
metric: body.name,
value: body.value,
rating: body.rating,
route: body.route,
ts: new Date(),
});
return NextResponse.json({ ok: true });
}
If you're debugging INP locally, open Chrome DevTools, go to Performance, enable "Web Vitals" in settings, then interact with the page. DevTools annotates the exact interaction that caused the worst INP with a red flag on the timeline. Pair that with the Chrome Performance panel long-task viewer and you'll usually find a synchronous handler doing something it shouldn't. Expensive setState, an unbatched reduce over a large list, or a third-party analytics script hijacking the click.
Send Web Vitals to your analytics endpoint
The hook doesn't care where the data goes. You can send it anywhere that accepts POST. Three real-world integrations:
The version in the mount example above. sendBeacon is the only transport that reliably survives a hard navigation on mobile Safari, which is exactly when your worst LCP numbers arrive. Regular fetch gets cancelled the instant the user taps a link.
Vercel Analytics vs a custom pipeline
Vercel Analytics installs in one line and gives you a p75 breakdown per route with zero configuration. If you host on Vercel and want a dashboard tomorrow, use it. Under the hood it uses the same web-vitals library and hits Vercel's ingestion endpoint from a script tag they inject; you don't need useReportWebVitals at all when the @vercel/analytics package is enabled.
You should reach for a custom pipeline when you need any of the following:
Joining Web Vitals with product analytics (feature flags, experiments, cohorts)
Alerting on p75 regressions per route in Datadog / Grafana / New Relic
Long-term storage beyond Vercel's retention window on lower tiers
Self-hosting outside Vercel, since the built-in analytics only fires on Vercel deployments
Correlating LCP element identity (which image or heading was the LCP) with the user session, something the packaged product doesn't expose
For a small marketing site, Vercel Analytics is the right answer. For a serious product where Web Vitals are on a business dashboard, own the pipeline.
Next.js-specific metrics: hydration and route change
Alongside the standard Web Vitals, the hook fires three Next.js-only events that don't exist in the vanilla web-vitals library:
Next.js-hydration: time from the DOMContentLoaded to the moment React finishes hydrating the initial page. Big numbers here point at oversized Client Component trees. If you haven't already, read my Next.js Bundle Analyzer guide, because hydration cost is proportional to Client JS shipped.
Next.js-route-change-to-render: time from clicking a <Link> to the new page painting. This includes any awaited Server Component data.
Next.js-render: render-only duration on subsequent renders.
useReportWebVitals((metric) => {
switch (metric.name) {
case 'Next.js-hydration':
// fired once per page load
trackHydration(metric.value);
break;
case 'Next.js-route-change-to-render':
trackNavigation(metric.value);
break;
case 'Next.js-render':
trackRender(metric.value);
break;
default:
// LCP, INP, CLS, FCP, TTFB - standard Web Vitals
trackVital(metric);
}
});
These are gold when you're optimizing a specific route because they let you separate "the page renders slowly" from "the network is slow" from "hydration is heavy." If Next.js-hydration is consistently high while TTFB is fine, your fix is Server Components, not caching.
Why are my Web Vitals bad in Next.js?
Nine times out of ten, one of these four things is happening. I'm listing them in the order I check on an audit:
LCP element is a lazy-loaded or unoptimized image. Set priority on the hero <Image>, use fetchPriority="high", and preload it via the App Router's <link rel="preload"> pattern. See the Next.js Image Optimization guide for the full checklist.
Hydration is heavy because too much is a Client Component. Every 'use client' at the top of a file drags its imports into the bundle. Push interactivity down to the leaves and keep parents on the server. React's Server Components docs explain the boundary model in detail.
Blocking third-party scripts. Analytics, chat widgets, and A/B testing tools loaded synchronously in <head> destroy TTFB and INP. Use <Script strategy="afterInteractive"> or strategy="lazyOnload" for anything that isn't critical.
CLS from web fonts and Suspense boundaries. Use next/font so fonts self-host and preload with correct fallback metrics, and give every Suspense boundary a skeleton the same size as the resolved content.
Field data vs lab data: don't confuse Lighthouse with reality
Lighthouse is a lab tool. It runs one throttled desktop or emulated mobile session on a synthetic device. Google Search rankings and Search Console use field data from the Chrome User Experience Report (CrUX), aggregated over 28 days of real Chrome traffic. That's exactly what useReportWebVitals gives you the first-party equivalent of, aka RUM (real user monitoring).
The two often disagree spectacularly. I've seen sites with a Lighthouse score of 99 and a p75 INP of 480ms in CrUX because the lab test never fired the interaction that's slow. If you optimize only what Lighthouse yells about, you can pass audits and still fail Core Web Vitals in Search. Instrument both, but trust the field.
A real before/after: LCP from 3.8s to 1.4s
An ecommerce client's product listing page: p75 LCP 3.8s (poor), p75 INP 340ms (needs-improvement), p75 CLS 0.18 (needs-improvement). Two afternoons of work, measured with useReportWebVitals shipping to Datadog RUM:
Marked the first product card's image priority, replaced hero swiper with a Server Component-rendered first slide. LCP dropped to 1.4s.
Wrapped filter state updates in startTransition, moved the filter sidebar behind next/dynamic with a skeleton. INP dropped to 120ms.
Set explicit width/height on all product images and swapped to next/font with correct fallback metrics. CLS dropped to 0.02.
All three moved from "fails" to "passes" in Search Console within the 28-day CrUX window. None of it was visible in Lighthouse before we started measuring in the field, because the lab run was hitting a warm cache with no interaction. The metrics dashboard was the whole point.
Frequently Asked Questions
Does useReportWebVitals work in the Pages Router?
Yes. In the Pages Router, export a reportWebVitals function from pages/_app.js, which is the pre-App Router API. In App Router, use the useReportWebVitals hook from next/web-vitals. The metric object shape is identical, so downstream analytics code doesn't need to change.
Do I need to install the web-vitals package?
No. next/web-vitals re-exports Google's web-vitals library and is bundled with Next.js. Adding web-vitals as a direct dependency just duplicates the code. Only install it directly if you're using it in a non-Next.js part of a monorepo.
What is a good INP score for a Next.js app?
Under 200ms at the p75 percentile is Google's "good" threshold. Between 200ms and 500ms is "needs improvement." Above 500ms is "poor" and hurts Search rankings. The most common culprits in Next.js are heavy Client Component trees and third-party scripts blocking the main thread during interactions.
Why do I get duplicate Web Vitals events?
Almost always because the reporter Client Component is mounted inside a layout that re-renders on navigation, so the hook re-subscribes. Move <WebVitals /> into the root app/layout.tsx where it mounts once for the tab's lifetime. Alternatively, dedupe on the server by metric.id.
Should I use Vercel Speed Insights or a custom pipeline?
Speed Insights is the right default on Vercel, installable in one line and giving you route-level p75 dashboards. Switch to a custom useReportWebVitals pipeline when you need to join Web Vitals with product analytics, alert on regressions in your own APM, self-host outside Vercel, or retain data longer than the plan's window.
Learn how to install Better Auth in Next.js 16, wire up Drizzle or Prisma, add email/password plus Google and GitHub social login, and lock down routes with proxy.ts and Server Action session checks.
Prefetch on the server, hydrate on the client. A 2026 guide to using TanStack Query v5 with the Next.js 16 App Router: HydrationBoundary, streaming, Server Actions, and the common hydration errors that break RSC setups.
Real effort estimates and a phase-by-phase playbook for migrating from Gatsby to Next.js 16 App Router: GraphQL to Server Components, plugin mapping, dynamic routes, redirects, and deployment.