Next.js Analytics: Measuring Real User Web Vitals
Measure real user Web Vitals in Next.js 15 — LCP, CLS, INP — without a paid tool. Practical setup with reportWebVitals, custom hooks, and dashboard tips.
Why Your Lighthouse Score Is Lying to You
Honestly, a perfect 100 on Lighthouse means almost nothing if your real users are experiencing 4-second LCPs on mid-range Android devices. Lighthouse runs in a controlled lab environment — throttled CPU, clean browser cache, no third-party scripts fighting for bandwidth. Real User Monitoring (RUM) is different. It captures what actual visitors experience the moment they land on your page, in the wild, on their own hardware.
Next.js has had a built-in hook for Web Vitals since v10. Most teams either don't know it exists or set it up once, point it at the console, and forget it. That's a waste. The data coming through that hook — LCP, CLS, FID, INP, TTFB, FCP — is exactly what Google uses to evaluate your Core Web Vitals score for Search rankings.
This article walks through setting up real user monitoring in a Next.js 15 app, from the initial reportWebVitals export all the way to a lightweight custom dashboard. No paid SaaS required. If you're already thinking about overall React performance patterns, our React performance guide covers the broader picture beyond just metrics.
Understanding the Six Web Vitals Metrics
Google's Core Web Vitals program currently tracks three field metrics that affect rankings: LCP (Largest Contentful Paint), CLS (Cumulative Layout Shift), and INP (Interaction to Next Paint, which replaced FID in March 2024). INP is the one most Next.js apps fail on — it measures the latency of all user interactions throughout a session, not just the first one.
The full set of metrics you'll get from Next.js includes three additional diagnostics: TTFB (Time to First Byte, which tells you if your server is slow), FCP (First Contentful Paint, which shows how fast the browser renders any content), and the old FID (First Input Delay) kept for backwards compatibility with older browser APIs. Each metric comes as an object with a name, value, rating ('good', 'needs-improvement', 'poor'), and a navigationType field.
The thresholds that matter: LCP under 2.5 s is 'good', 2.5–4 s is 'needs improvement', over 4 s is 'poor'. CLS under 0.1 is 'good'. INP under 200 ms is 'good', 200–500 ms 'needs improvement', over 500 ms 'poor'. These aren't arbitrary — Google publishes them and updates them. Always check the current values at web.dev rather than trusting a cached blog post (including this one).
Setting Up reportWebVitals in Next.js 15
Next.js 15 ships with the web-vitals package (v4.2.4 as of this writing) bundled internally. You don't install anything extra. You just export a named function called reportWebVitals from your app/layout.tsx — or, if you're still on the Pages Router, from pages/_app.tsx.
Here's the minimal working setup for the App Router. The key thing people miss is that this function runs on the client, so you can call fetch, push to a data layer, or write to localStorage directly inside it:
// app/layout.tsx (Next.js 15, App Router)
import type { NextWebVitalsMetric } from 'next/app'
export function reportWebVitals(metric: NextWebVitalsMetric) {
const { name, value, id, rating, navigationType } = metric
// Quick filter: only send to your endpoint in production
if (process.env.NODE_ENV !== 'production') {
console.log(`[vitals] ${name}: ${Math.round(value)} (${rating})`)
return
}
const body = JSON.stringify({
name,
value: Math.round(name === 'CLS' ? value * 1000 : value),
id,
rating,
navigationType,
url: window.location.href,
timestamp: Date.now(),
})
// Use sendBeacon for reliability during page unload
if (navigator.sendBeacon) {
navigator.sendBeacon('/api/vitals', body)
} else {
fetch('/api/vitals', {
method: 'POST',
body,
keepalive: true,
headers: { 'Content-Type': 'application/json' },
})
}
}The Math.round(name === 'CLS' ? value * 1000 : value) trick is worth explaining. CLS values are tiny floats like 0.023 — multiplying by 1000 gives you an integer that's easier to store and aggregate. All other metrics are already in milliseconds and just need rounding. This pattern comes straight from the web-vitals library docs.
Building the API Route to Receive Vitals Data
You need somewhere to send that data. The simplest option is a Next.js Route Handler that writes to a database, pushes to a queue, or forwards to an analytics backend. Here's a minimal Route Handler using the App Router that validates the payload and logs it — swap in your actual storage layer:
// app/api/vitals/route.ts
import { NextRequest, NextResponse } from 'next/server'
interface VitalsPayload {
name: string
value: number
id: string
rating: 'good' | 'needs-improvement' | 'poor'
navigationType: string
url: string
timestamp: number
}
export async function POST(req: NextRequest) {
try {
const payload: VitalsPayload = await req.json()
// Basic validation
const allowed = ['LCP', 'CLS', 'INP', 'FID', 'TTFB', 'FCP']
if (!allowed.includes(payload.name)) {
return NextResponse.json({ error: 'unknown metric' }, { status: 400 })
}
// TODO: replace with your DB write, e.g.:
// await db.insert(vitalsTable).values(payload)
console.log('[api/vitals]', payload)
return NextResponse.json({ ok: true })
} catch {
return NextResponse.json({ error: 'bad request' }, { status: 400 })
}
}
// Tell Next.js not to cache this route
export const dynamic = 'force-dynamic'One thing to think about: do you actually need a custom endpoint? If you're already using Vercel, their Speed Insights product captures Web Vitals with zero code. But it costs money at scale, and you don't own the raw data. A self-hosted approach gives you full query flexibility — you can slice by URL path, device type, or geographic region if you pass those fields along with each event.
Segmenting Vitals by Route and Device Type
Aggregate scores hide everything that matters. Your blog post pages might have excellent LCP while your checkout flow is sitting at 3.8 s. You can't know without segmenting by route. The url field in each metric event gives you the full URL; strip query strings, hash fragments, and dynamic segments before storing.
Device type is the other axis you absolutely want. A regex on navigator.userAgent is imprecise — prefer the navigator.deviceMemory and navigator.hardwareConcurrency APIs to infer device tier. Send deviceMemory (values: 0.25, 0.5, 1, 2, 4, 8 GB) with every event. You'll almost certainly find that your 'poor' INP scores are concentrated on devices with 1 GB or less of RAM.
Here's a small utility that enriches each metric event before sending it:
// lib/vitals-meta.ts
export function getDeviceMeta() {
return {
// deviceMemory is available in Chrome 63+, undefined elsewhere
deviceMemory: (navigator as any).deviceMemory ?? null,
// logical CPU cores
hardwareConcurrency: navigator.hardwareConcurrency ?? null,
// connection type: '4g', '3g', 'slow-2g', etc.
connectionType:
(navigator as any).connection?.effectiveType ?? null,
// viewport width bucket for rough device-size segmentation
viewportBucket:
window.innerWidth < 768
? 'mobile'
: window.innerWidth < 1280
? 'tablet'
: 'desktop',
}
}Merge the output of getDeviceMeta() into your vitals payload before calling sendBeacon. This adds maybe 40 bytes per event — well worth it for the segmentation you get in return.
Diagnosing LCP and INP Problems in Next.js Apps
High LCP is almost always one of four things: a large image without priority, a font render-blocking above-the-fold text, a slow server response (check TTFB first), or a client-side data fetch that delays the main content paint. Next.js's <Image> component with priority on your hero images should be your first move. If TTFB is above 600 ms consistently, look at your database queries and cold-start latency on serverless functions.
INP is trickier. It measures every interaction — clicks, key presses, form inputs — throughout the entire session. Common culprits in Next.js apps include synchronous state updates that block the main thread for more than 50 ms, heavy useEffect callbacks triggered on user actions, and unoptimised third-party scripts (Google Tag Manager is a repeat offender). The fix is usually startTransition to defer non-urgent state updates or moving expensive work into a Web Worker.
What does a bad INP look like in practice? A user clicks a filter button in your product list. React starts re-rendering 200 items synchronously. The browser is locked for 280 ms. The button doesn't visually respond. The user clicks again. That's an INP event of 280 ms — 'needs improvement'. Wrapping the state setter in React.startTransition lets the browser handle paint and input events first, then apply the update. If you're dealing with heavy component trees, a theme toggle pattern is a good low-stakes place to practice startTransition before applying it to heavier interactions.
Visualising Web Vitals Without a Paid Dashboard
You've got data flowing into a database. Now what? The minimum viable dashboard is a simple admin page with three numbers: p75 LCP, p75 CLS, and p75 INP over the last 7 days. Google evaluates your Core Web Vitals at the 75th percentile of field data — median isn't good enough. Build your SQL queries accordingly.
For a quick internal dashboard, a React Server Component with direct DB queries is the most straightforward approach — no API layer needed. Group by name, compute percentiles with a percentile_disc(0.75) window function (Postgres supports this natively), and render colour-coded badges ('good' → green, 'needs-improvement' → amber, 'poor' → red). You can style those badges with a toast notification pattern repurposed for status indicators — the bg-green-500/20 text-green-400 ring-1 ring-green-500/40 Tailwind classes work perfectly for this.
If you want charts, Recharts v2.12 renders well inside Server Components when you wrap the chart in a 'use client' boundary. A simple line chart of p75 LCP over 30 days — one data point per day — tells you immediately whether a deploy made things better or worse. That feedback loop is worth more than any synthetic monitoring score.
Sending Vitals to Third-Party Analytics (GA4, PostHog, Fathom)
Sometimes you want Web Vitals data alongside your existing analytics events rather than in a separate table. All three of GA4, PostHog, and Fathom accept custom events, so you can pipe vitals directly into them from reportWebVitals.
For GA4 with gtag, the pattern is straightforward. For PostHog it's even cleaner since the PostHog JS SDK (posthog-js v1.140.x) has a capture method that accepts arbitrary properties. The advantage of pushing to PostHog is that you can correlate Web Vitals scores with user cohorts, feature flags, and session recordings — that's genuinely useful when you're trying to trace a performance regression to a specific code change.
// Sending to PostHog from reportWebVitals
// (import posthog-js in your layout and call posthog.init() once)
import posthog from 'posthog-js'
export function reportWebVitals(metric: NextWebVitalsMetric) {
if (typeof window === 'undefined') return
posthog.capture('web_vital', {
metric_name: metric.name,
// Store as integer ms (or CLS * 1000)
metric_value:
metric.name === 'CLS'
? Math.round(metric.value * 1000)
: Math.round(metric.value),
metric_rating: metric.rating,
metric_id: metric.id,
page_url: window.location.pathname,
navigation_type: metric.navigationType,
})
}One thing you'll notice when doing this: Web Vitals events fire at different times. FCP and TTFB fire early. LCP fires when the paint is done. CLS and INP are only finalised when the user leaves the page or the page goes into a background tab. That's exactly why sendBeacon matters — regular fetch calls get cancelled on page unload. PostHog's SDK handles this internally, but if you're rolling your own endpoint, always use sendBeacon as the primary path.
FAQ
Yes. Export reportWebVitals from your root app/layout.tsx and Next.js picks it up automatically. The function runs client-side only, so you have access to browser APIs like navigator.sendBeacon. The same export also works in pages/_app.tsx if you're on the Pages Router.
FID measured only the first user interaction delay. INP (Interaction to Next Paint) measures the latency of every interaction throughout the session and takes the worst one. Google replaced FID with INP as a Core Web Vitals ranking signal in March 2024. Focus on INP — FID is kept in the Next.js hook for backwards compatibility but no longer affects Search rankings.
The reportWebVitals function fires regardless of authentication state — it runs on the client for every page render. Your /api/vitals endpoint should authenticate the request though. The easiest approach is checking that the request comes from your own domain via the Origin header, or attaching a short-lived token to the payload from a session cookie.
Multiply by 1000 before storing to get an integer — a CLS of 0.087 becomes 87. Reconstruct the real value by dividing by 1000 when displaying. This avoids floating-point precision issues in some databases and keeps your column type consistent with the other millisecond metrics.
Segment your LCP data by navigationType — pay special attention to 'navigate' (full page loads) vs 'reload' vs 'back-forward'. Also segment by the url field. High LCP concentrated on a single route usually points to a large image without priority, a slow database query in a Server Component, or a cold-start latency issue on a serverless function handling that route.
Yes. Vercel Speed Insights injects its own script separately from the reportWebVitals hook. Both will collect data independently. There's no conflict, just duplication — which can actually be useful as a cross-check during initial setup to validate that your custom pipeline is capturing the same values.