useReportWebVitals()

Receive Core Web Vitals metrics on the client and forward them to an analytics endpoint.

Signature
import { useReportWebVitals } from 'next/web-vitals'

type ReportWebVitalsCallback = Parameters<typeof useReportWebVitals>[0]

useReportWebVitals(reportWebVitalsFn: ReportWebVitalsCallback): void

Parameters

ParameterTypeDescription
$reportWebVitalsFn(metric: { id: string; name: string; delta: number; value: number; rating: 'good' | 'needs-improvement' | 'poor'; navigationType: string; entries: PerformanceEntry[] }) => voidCallback 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

PropertyMeaning
nameTTFB, FCP, LCP, FID, CLS or INP.
valueThe measured value, usually in milliseconds (CLS is unitless).
deltaChange since the previous report of this metric.
rating'good', 'needs-improvement' or 'poor'.
idUnique per page load — use it to group or deduplicate.
navigationType'navigate', 'reload', 'back-forward', 'back-forward-cache', 'prerender' or 'restore'.
entriesThe 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.sendBeacon where available; a plain fetch can 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

Example · typescript
'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.

Example · typescript
// 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.

Example · typescript
'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.

Example · typescript
'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

  • Be the first to comment on this lesson.