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 16next/web-vitalsentry and forwards each metric (LCP,INP,CLS, plus others you can ignore here) to your callback. The bundledweb-vitalslibrary (v4.x in this stack) exportsonCLS,onINP, andonLCPdirectly, which is what the alternative uses. deviceClass()is intentionally coarse:mobilebelow 768px, otherwisedesktop. It is a segmentation label, not a device fingerprint. Keep it coarse so segments stay readable and privacy-safe.releaseshould be set fromNEXT_PUBLIC_RELEASEat 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.sendBeaconwith afetch+keepalivefallback 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]), bydevice(mobilevsdesktop), and byrelease. - For each slice compute the 75th percentile (p75) of
valueseparately 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.
