Back to journal
Next.js6 min readSeptember 11, 2026

Next.js RUM snippet: measure LCP, INP, CLS per route and segment

Copy-paste App Router RUM snippet to log LCP, INP, CLS by route, device, and release. Compute p75 mobile/desktop split. CrUX vs RUM and lab notes included.

#Next.js#Core Web Vitals#RUM#Performance#App Router#INP
Next.js RUM snippet: measure LCP, INP, CLS per route and segment

Lab scores don't decide search experience. Field data does, segmented by route and device.

This snippet logs real-user LCP, INP, CLS from Next.js App Router with route, device class, and release tag. Then you compute 75th percentile per segment over a 28-day window.

Good thresholds: LCP ≤2.5s, INP ≤200ms, CLS ≤0.1. Apply per route + mobile/desktop, not as one site-wide average.

This is a practical companion to the Core Web Vitals guide, which defines the route-segment-bottleneck-verify workflow, the decision matrix, and the triage worksheet. That guide explains what to investigate; this note gives you the copy-paste collector that supplies the route-segmented field data it assumes.

1. Add the reporter — `app/web-vitals.tsx`

Create a client component that reports the three metrics with the route, a coarse device class, and the release tag. Mount it once in the root layout.

'use client';
import { useReportWebVitals } from 'next/web-vitals';

function deviceClass(){
  if(typeof window==='undefined') return 'unknown';
  return window.innerWidth < 768 ? 'mobile' : 'desktop';
}

export function WebVitals(){
  useReportWebVitals((metric)=>{
    if(!['LCP','INP','CLS'].includes(metric.name)) return;
    const body = JSON.stringify({
      name: metric.name, value: metric.value, id: metric.id,
      route: window.location.pathname,
      device: deviceClass(),
      release: process.env.NEXT_PUBLIC_RELEASE || 'unknown',
      url: window.location.href.slice(0,300)
    });
    if(navigator.sendBeacon) navigator.sendBeacon('/api/rum', body);
    else fetch('/api/rum',{method:'POST',body,keepalive:true});
  });
  return null;
}

Add <WebVitals /> in app/layout.tsx.

Direct web-vitals alternative:

import { onCLS, onINP, onLCP } from 'web-vitals';
onCLS(send); onINP(send); onLCP(send);
function send(m){ /* same route/device/release payload to /api/rum */ }

Notes on this snippet:

  • It runs on a Next.js App Router client component via useReportWebVitals. The hook exists in the installed Next.js 16 next/web-vitals entry and forwards each metric (LCP, INP, CLS, plus others you can ignore here) to your callback. The bundled web-vitals library (v4.x in this stack) exports onCLS, onINP, and onLCP directly, which is what the alternative uses.
  • deviceClass() is intentionally coarse: mobile below 768px, otherwise desktop. It is a segmentation label, not a device fingerprint. Keep it coarse so segments stay readable and privacy-safe.
  • release should be set from NEXT_PUBLIC_RELEASE at build time (for example a short commit hash). Without it you cannot tell whether a percentile shift came from a code change or from traffic mix.
  • sendBeacon with a fetch + keepalive fallback keeps the report from blocking navigation. The payload is JSON; keep the endpoint path consistent with your collector.

2. Minimal API — `app/api/rum/route.ts`

Validate name/value/route/device, store with timestamp. Do not log full IP/UA unhashed.

The endpoint is intentionally thin: accept only LCP, INP, or CLS names, require a numeric value and a string route starting with /, accept device as mobile, desktop, or unknown, attach the server timestamp and the release tag, and persist the record to your approved store. Keep request headers and addresses out of the stored row, or hash them before storage, and apply the project's privacy rules before deployment. Replace /api/rum with the approved collection route if your project already defines one.

3. Analyze: group by `route + device + release`

Group by route + device + release, last 28 days, compute p75 for each metric. Fix pages where mobile p75 fails while desktop passes — common with images/JS on mobile.

Concretely:

  • Slice the last 28 days of stored rows by exact route (for example /, /blog/[slug]), by device (mobile vs desktop), and by release.
  • For each slice compute the 75th percentile (p75) of value separately for LCP, INP, and CLS.
  • Compare each p75 against the thresholds above within the same slice. A mobile LCP p75 above 2.5s while desktop passes points at image sizing, discovery order, or client JavaScript that only hurts narrow viewports — not at a site-wide average.
  • Report p75 per template: e.g. /blog/[slug] mobile, / desktop. Do not average all routes into one site score.

Notes

  • CrUX vs RUM: CrUX is Chrome 28-day field data at origin/page level. Your RUM is per-route, per-release, all browsers you instrument. Use both; debug with RUM.
  • Lab proxy: Total Blocking Time in lab hints at INP risk, it does not equal INP. Confirm with field INP.
  • Do not average all routes into one site score. Report p75 per template: e.g. /blog/[slug] mobile, / desktop.

Checklist before ship

  • Runs on App Router client component.
  • Thresholds cited as above (LCP ≤2.5s, INP ≤200ms, CLS ≤0.1).
  • Mobile/desktop split preserved in every comparison.
  • 28-day window used for each p75.
  • No site-wide average claim.

Slow on real phones but fast in lab?

> Slow on real phones but fast in lab? Send a brief — we instrument RUM per route and fix the failing template.

Your project with CodeAustral

Explore the scope and build your estimate.

Build my estimate