useReportWebVitals()
Receive Core Web Vitals metrics on the client and forward them to an analytics endpoint.
import { useReportWebVitals } from 'next/web-vitals'
type ReportWebVitalsCallback = Parameters<typeof useReportWebVitals>[0]
useReportWebVitals(reportWebVitalsFn: ReportWebVitalsCallback): voidParameters
| Parameter | Type | Description |
|---|---|---|
$reportWebVitalsFn | (metric: { id: string; name: string; delta: number; value: number; rating: 'good' | 'needs-improvement' | 'poor'; navigationType: string; entries: PerformanceEntry[] }) => void | Callback invoked with each Web Vitals metric. Keep the function reference stable across renders — a new reference is replayed with all metrics collected so far, which produces duplicate reports. |
Return value
voidNothing. The callback is invoked once per metric, and a newly passed function is called with every metric collected so far.
Errors
Using it without the 'use client' directive fails, because it is a Client Component hook.
useReportWebVitals hands you each Core Web Vital as the browser measures it, so you can ship real-user performance data to your own endpoint instead of relying on lab numbers. The conventional shape is a tiny component that renders null and is mounted once in the root layout — that keeps the client boundary around a component with no UI rather than around the whole layout.
The metric object
| Property | Meaning |
|---|---|
name | TTFB, FCP, LCP, FID, CLS or INP. |
value | The measured value, usually in milliseconds (CLS is unitless). |
delta | Change since the previous report of this metric. |
rating | 'good', 'needs-improvement' or 'poor'. |
id | Unique per page load — use it to group or deduplicate. |
navigationType | 'navigate', 'reload', 'back-forward', 'back-forward-cache', 'prerender' or 'restore'. |
entries | The raw PerformanceEntry objects behind the value. |
What trips people up
- Define the callback outside the component. A new function identity on each render is replayed with every metric collected so far, so an inline arrow duplicates your data and inflates the counts.
- Use
navigator.sendBeaconwhere available; a plainfetchcan be cancelled when the page unloads. - Metrics arrive at different times — CLS and INP typically at page hide, not at load. An empty dashboard right after deploy usually means too short a wait.
Example
'use client'
import { useReportWebVitals } from 'next/web-vitals'
type ReportWebVitalsCallback = Parameters<typeof useReportWebVitals>[0]
// Defined at module scope so the reference never changes between renders.
// An inline arrow would be replayed with every metric collected so far.
const reportWebVitals: ReportWebVitalsCallback = (metric) => {
const body = JSON.stringify({
id: metric.id,
name: metric.name,
value: metric.value,
rating: metric.rating,
navigationType: metric.navigationType,
})
const url = 'https://example.com/analytics'
if (navigator.sendBeacon) {
navigator.sendBeacon(url, body)
} else {
fetch(url, { body, method: 'POST', keepalive: true })
}
}
export function WebVitals() {
useReportWebVitals(reportWebVitals)
return null
}When to use it
- POST real-user LCP and INP to your own analytics endpoint with sendBeacon.
- Forward metrics to Google Analytics as events keyed by metric.id.
- Log only metrics whose rating is 'poor' to keep sampling volume down.
- Compare a page's field data against the lab numbers from a synthetic run.
Frequently asked questions
Why are my Web Vitals reported twice?
The callback identity changed between renders. Every new function passed to useReportWebVitals is called with all metrics collected so far, so define the callback at module scope or wrap it in useCallback with a stable dependency list.
Where should useReportWebVitals live in the App Router?
In a small component with the 'use client' directive that returns null, imported into the root layout. That keeps the client boundary around the reporter instead of the whole layout.
Which metrics does useReportWebVitals report?
TTFB, FCP, LCP, FID, CLS and INP. Branch on metric.name, and note that CLS and INP usually arrive when the page is hidden rather than at load.
More examples
Mounting the reporter once, in the root layout
Because WebVitals renders null, the only thing crossing the client boundary is the reporting logic — the layout and everything else stay Server Components. Mounting it in the root layout means every route reports without any per-page wiring.
// app/layout.tsx — stays a Server Component
import { WebVitals } from './_components/web-vitals'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>
<WebVitals />
{children}
</body>
</html>
)
}Branching on the metric name
One callback receives all six vitals — TTFB, FCP, LCP, FID, CLS and INP — so switching on metric.name is how you treat them differently. metric.entries carries the raw PerformanceEntry list, which for a poor LCP identifies the element that painted last.
'use client'
import { useReportWebVitals } from 'next/web-vitals'
type ReportWebVitalsCallback = Parameters<typeof useReportWebVitals>[0]
const handleWebVitals: ReportWebVitalsCallback = (metric) => {
switch (metric.name) {
case 'LCP':
if (metric.rating === 'poor') {
console.warn('Slow LCP', metric.value, metric.entries)
}
break
case 'CLS':
// CLS is unitless; multiply before sending to integer-only backends
break
default:
break
}
}
export function WebVitals() {
useReportWebVitals(handleWebVitals)
return null
}Sending to Google Analytics
GA event values must be integers, so CLS (a small fraction) is scaled by 1000 and everything else is rounded. Passing metric.id as the event label lets you reconstruct per-page-load distributions and compute percentiles rather than only averages.
'use client'
import { useReportWebVitals } from 'next/web-vitals'
type ReportWebVitalsCallback = Parameters<typeof useReportWebVitals>[0]
const sendToGoogleAnalytics: ReportWebVitalsCallback = (metric) => {
window.gtag('event', metric.name, {
// values must be integers
value: Math.round(
metric.name === 'CLS' ? metric.value * 1000 : metric.value
),
event_label: metric.id, // id unique to the current page load
non_interaction: true, // avoids affecting bounce rate
})
}
export function WebVitals() {
useReportWebVitals(sendToGoogleAnalytics)
return null
}
Discussion