
Next.js useReportWebVitals
useReportWebVitals is the one hook in the Next.js API surface whose entire job is to hand you a stream of performance measurements as they become available in the browser. It doesn't render anything, it doesn't fetch anything, and it doesn't ship an opinion about where those numbers should go — it's a thin, purpose-built bridge between the browser's performance APIs and whatever you decide to do with the data. This article is a compact reference for the hook itself: its exact signature, the shape of the object it hands you, and the placement rules that trip people up. It assumes you already know why you'd want Core Web Vitals data in the first place — if you're looking for a full walkthrough of wiring this up to Google Analytics or a custom ingestion endpoint, this blog already has a dedicated "Adding analytics" guide for that; this piece stays narrowly on the hook's API.
Import path and signature
The hook lives in next/web-vitals, not next/navigation or next itself — an easy detail to get wrong from memory:
import { useReportWebVitals } from "next/web-vitals";
Its signature is a single callback parameter:
useReportWebVitals(callback: (metric: Metric) => void): void
Every time a new metric becomes available, Next.js invokes your callback with that metric. Because it's a hook, it can only be called from a Client Component — which forces a specific placement pattern, covered below.
The Metric object, field by field
The single argument your callback receives carries everything Next.js knows about that measurement:
| Field | Type | What it tells you |
|---|---|---|
id | string | A unique identifier for this metric instance, scoped to the current page load. Two different LCP reports from two different page loads will have different ids. |
name | string | Which metric this is — one of TTFB, FCP, LCP, FID, CLS, or INP. |
value | number | The measured value, generally in milliseconds (CLS is the exception — it's a unitless score). |
delta | number | How much the value changed since the last time this metric was reported. Some metrics (like CLS) can report multiple times per page load as new layout shifts occur; delta tells you the increment, not just the running total. |
rating | "good" | "needs-improvement" | "poor" | A qualitative bucket, computed by comparing value against the metric's published thresholds. |
navigationType | string | What kind of navigation produced this page load — "navigate", "reload", "back-forward", "back-forward-cache", "prerender", or "restore". |
entries | PerformanceEntry[] | The raw browser Performance API entries backing this metric, for anyone who wants to dig past the summary numbers. |
A few of these are worth internalizing on their own, separate from what the docs table implies:
delta exists because some metrics fire more than once. TTFB and FCP report exactly once per page load — there's nothing to "update." CLS is different: every layout shift that occurs during the page's lifetime can trigger another callback invocation with an updated cumulative score. If you're summing values client-side instead of trusting delta, you'll double-count every layout shift after the first.
navigationType matters more than it looks. A "back-forward-cache" restore is not a real navigation in any meaningful performance sense — the page was frozen and thawed, not re-fetched and re-rendered. If your ingestion pipeline treats every callback invocation as an equivalent "page view," BFCache restores will quietly skew your LCP and FCP distributions toward suspiciously fast numbers that don't reflect a real cold load.
id is what makes percentile math possible. A single metric name reported across thousands of page loads is just a stream of numbers; id is what lets an analytics backend group and de-duplicate them per page load instead of treating every emission as an independent event.
Where the hook has to live
Because useReportWebVitals is a hook, any component that calls it needs the 'use client' directive. The docs' own recommended pattern is to isolate it in its own tiny component rather than adding the directive to something larger:
// app/components/web-vitals.tsx
"use client";
import { useReportWebVitals } from "next/web-vitals";
export function WebVitals() {
useReportWebVitals((metric) => {
console.log(metric);
});
return null;
}
// app/layout.tsx
import { WebVitals } from "./components/web-vitals";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<WebVitals />
{children}
</body>
</html>
);
}
This isn't just tidiness. Marking an entire root layout 'use client' would push every Server Component convenience out of the tree above your actual page content — no server-side data fetching in the layout, no async Server Components as ancestors, nothing. A one-file, null-returning component keeps the client boundary as small as physically possible: exactly the one hook that requires it, and nothing else.
The callback-identity gotcha
The docs are explicit about one constraint that's easy to skim past: the reference passed to useReportWebVitals should stay stable across renders, or you risk reporting duplicate data. This is a plain consequence of how React effects work — if you pass an inline arrow function, it's a new function on every render, and depending on how your surrounding component re-renders, the hook can end up re-registering its listener and reporting metrics you've already seen.
The fix is the same one you'd reach for anywhere else in React: define the callback outside the component, or wrap it in useCallback if it needs to close over something from render:
"use client";
import { useReportWebVitals } from "next/web-vitals";
// Defined once, at module scope — never recreated across renders.
const reportMetric: Parameters<typeof useReportWebVitals>[0] = (metric) => {
console.log(metric);
};
export function WebVitals() {
useReportWebVitals(reportMetric);
return null;
}
If your real callback needs to reach into component state or props, useCallback with a correct dependency array gets you the same stability without hoisting the function out of the component entirely.
Typing the callback
next/web-vitals doesn't export a standalone Metric type you import directly — instead, TypeScript users typically derive it from the hook's own signature:
import { useReportWebVitals } from "next/web-vitals";
type ReportWebVitalsCallback = Parameters<typeof useReportWebVitals>[0];
const handleWebVitals: ReportWebVitalsCallback = (metric) => {
// metric is fully typed here
};
Parameters<typeof fn>[0] is a small utility-type pattern worth recognizing on sight — it pulls the type of a function's first argument without needing that type to be separately exported. It shows up anywhere a library exposes a function but not the shape of what it accepts.
What this hook is not for
It's worth being precise about scope, since it's easy to reach for this hook and expect more than it offers:
- It doesn't batch or buffer for you. Every metric emission calls your callback immediately. If you want batching before you send data over the network, that's your responsibility to implement in the callback.
- It doesn't run on the server. These are real-user, in-browser measurements — there's no server-side equivalent, and there can't be, since Core Web Vitals are inherently client-side timing data.
- It doesn't retry failed sends. If your callback does a
fetch()to ship the metric somewhere and that request fails,useReportWebVitalshas no idea and won't call you again for that same metric. - It's App Router-only in this form. The Pages Router has its own
reportWebVitalsexport convention in_app.js, which is a different mechanism with a similar name — don't confuse the two if you're working across both routers, or migrating between them.
Key Takeaways
| Question | Answer |
|---|---|
| Where does it come from? | import { useReportWebVitals } from 'next/web-vitals' |
| What does it give you? | A callback invoked once per available metric, receiving a Metric object |
| Which fields need special attention? | delta (increments, not totals), navigationType (filter out BFCache restores), id (groups per page load) |
| Where must it be called? | A 'use client' component — ideally a tiny, isolated one imported into your root layout |
| What's the easy-to-miss gotcha? | The callback reference must stay stable across renders, or you'll double-report metrics |
| Does it work in the Pages Router? | No — that router uses a separate reportWebVitals export in _app.js instead |
Used correctly, useReportWebVitals is close to invisible in your codebase: one small component, one stable callback, and a stream of real-user performance data flowing wherever you point it.


