Type something to search...
Nextjs Adding analytics

Nextjs Adding analytics

Performance is no longer a "nice to have" you tack on after launch. Core Web Vitals are a confirmed Google ranking signal, slow pages measurably kill conversion rates, and every framework decision you make — from how you fetch data to how you load a font — eventually shows up as a number a real user experienced. The problem is that most of those numbers are invisible unless you go looking for them. Your local dev server feels instant. A user on a three-year-old Android phone over a spotty 4G connection in a different hemisphere has a very different experience, and you will never know how different unless you measure it.

Next.js has first-class support for exactly this kind of measurement, and it ships as a small, unopinionated hook rather than a heavyweight analytics SDK bolted onto your app. That's a deliberate design choice: Next.js doesn't want to decide where your performance data goes, it just wants to make it trivially easy to capture. What you do with that data — log it, ship it to Google Analytics, pipe it into Datadog, or hand it to Vercel's managed dashboard — is entirely up to you.

This article walks through both halves of that story: the low-level useReportWebVitals hook that gives you raw access to every metric as it's collected, and the newer instrumentation-client file that lets you initialize broader monitoring before your app even starts rendering. Along the way I'll cover the gotchas the official docs mention only in passing — the stale-closure trap that causes duplicate reporting, why FID is quietly obsolete even though it still shows up in the metric list, and when reaching for a managed service is genuinely the better call over rolling your own.

Two Ways to Measure Performance in Next.js

Before writing any code, it's worth understanding that Next.js gives you two fundamentally different paths, and they aren't mutually exclusive:

Roll your own with useReportWebVitals. This is a React hook, bundled with Next.js itself, that fires a callback every time a new performance metric becomes available. You own the entire pipeline after that point — where the data goes, how it's aggregated, what dashboard (if any) it ends up in.

Use a managed service. Vercel, the company behind Next.js, offers a hosted analytics product that wires itself up automatically and gives you a dashboard with zero backend work. If you're already deploying to Vercel, this is close to a one-line integration. If you're self-hosting or deploying elsewhere, it's not available to you in the same turnkey form, and useReportWebVitals becomes your primary tool.

Most teams end up using the hook even when they also use a managed service, because the hook is also the escape hatch for shipping metrics to any destination — your own logging pipeline, a third-party APM tool, or a custom dashboard your data team already built. Understanding it well is worth the twenty minutes it takes, regardless of which analytics vendor you ultimately choose.

Client Instrumentation: The Broader Entry Point

Next.js also gives you a mechanism that sits a level above web-vitals specifically: the instrumentation-client.js (or .ts) file. Drop this file in your project root and Next.js runs it before your application's frontend code starts executing — meaning it's the earliest hook you have into the client runtime.

// instrumentation-client.js

// Initialize analytics before the app starts
console.log("Analytics initialized");

// Set up global error tracking
window.addEventListener("error", (event) => {
  // Send to your error tracking service
  reportError(event.error);
});

This is the right place for things that need to exist for the entire lifetime of the page, not just for a specific component: initializing a third-party monitoring SDK, wiring up a global window.onerror handler, or setting up a session-replay tool that needs to start capturing before your React tree even mounts.

It's easy to conflate this with useReportWebVitals, but they solve different problems. instrumentation-client is about bootstrapping client-side tooling as early as possible. useReportWebVitals is specifically about capturing Core Web Vitals metrics as React renders your pages. You'll often use both: instrumentation-client to initialize your error-tracking or analytics SDK, and the hook to feed it performance data once your components are on screen.

One practical note the docs don't spell out: because this file runs before hydration, anything inside it should be defensive about window and other browser globals not yet being in a "normal" state — some polyfills or third-party scripts haven't loaded yet at this point in the lifecycle. Keep the logic in this file minimal and synchronous where possible; if you need to do anything async (like dynamically importing a heavy SDK), gate it so it doesn't block subsequent script execution.

Building Your Own Web Vitals Reporter

The core building block is the useReportWebVitals hook, importable from next/web-vitals. Here's the minimal version:

// app/_components/web-vitals.js
"use client";

import { useReportWebVitals } from "next/web-vitals";

export function WebVitals() {
  useReportWebVitals((metric) => {
    console.log(metric);
  });
}
// app/layout.js
import { WebVitals } from "./_components/web-vitals";

export default function Layout({ children }) {
  return (
    <html>
      <body>
        <WebVitals />
        {children}
      </body>
    </html>
  );
}

The structure here matters more than it looks. Notice that WebVitals is its own file, marked 'use client', and imported into the root layout — rather than adding 'use client' directly to layout.js. This is a pattern worth internalizing across the App Router generally, not just for analytics: push the client boundary down to the smallest component that actually needs it. If you slapped 'use client' on the root layout itself, you'd force the entire tree beneath it into the client bundle, losing the Server Component benefits (smaller JS payloads, direct data access, no hydration cost) for every single page in your app. By isolating the hook inside its own tiny component, the rest of your layout — navigation, footers, providers that don't need client interactivity — stays on the server.

This is a one-line component, but it's doing real architectural work.

Understanding the Metric Object

Every time the callback fires, it receives a metric object. The docs describe the shape, but seeing it laid out is more useful than reading prose about it:

PropertyTypeDescription
idstringUnique identifier for this metric within the current page load. Use this to deduplicate or correlate with a specific navigation.
namestringWhich metric this is — TTFB, FCP, LCP, FID, CLS, or INP.
valuenumberThe measured value, generally in milliseconds (CLS is a unitless score, not milliseconds — more on that below).
deltanumberThe change since the last time this metric was reported. Useful if you're aggregating incrementally rather than taking a single final reading.
rating"good" | "needs-improvement" | "poor"A pre-computed qualitative bucket, based on Google's published thresholds. This saves you from hardcoding threshold numbers yourself.
navigationTypestringWhat triggered this measurement — "navigate", "reload", "back-forward", "back-forward-cache", or "restore".
entriesPerformanceEntry[]The raw underlying Performance API entries used to compute the metric, for anyone who wants to dig deeper than the summary value.

The rating field is worth calling attention to because it quietly saves you from a common mistake: hardcoding Google's Core Web Vitals thresholds (like "LCP under 2.5s is good") into your own code. Those thresholds have changed before and will likely change again as Google refines its scoring model. Reading metric.rating instead of reimplementing the threshold logic means your app automatically tracks whatever Next.js/the underlying web-vitals library considers current best practice, without you needing to ship an update every time Google tweaks a number.

The Metrics Themselves — And One That's Quietly Outdated

Next.js reports six metrics out of the box:

  • TTFB (Time to First Byte) — how long the browser waited for the first byte of the response. This is almost entirely a server/infrastructure metric: your hosting region, your caching strategy, and how much work happens before your Server Components can start streaming all show up here.
  • FCP (First Contentful Paint) — when the first piece of DOM content (text, image, canvas) is painted. A rough proxy for "does this feel like it's loading."
  • LCP (Largest Contentful Paint) — when the largest visible content element finishes rendering. This is usually your hero image, a large heading, or a prominent block of text, and it's the metric most people mean when they informally say "how fast does this page load."
  • FID (First Input Delay) — how long the browser took to respond to the first user interaction (a click, a tap, a keypress).
  • CLS (Cumulative Layout Shift) — a unitless score representing how much visible content shifted around unexpectedly during the page's lifetime. This is the "why did the button move right as I was about to tap it" metric.
  • INP (Interaction to Next Paint) — measures responsiveness across all interactions during a page's lifetime, not just the first one.

Here's something the Next.js docs don't mention, because it's a fact about the broader Web Vitals program rather than about Next.js itself: FID was officially retired by Google in March 2024 and replaced by INP as the responsiveness metric that counts toward Core Web Vitals and search ranking. Next.js (via the underlying web-vitals library it wraps) still reports FID for backwards compatibility and because some teams have existing dashboards built around it, but if you're setting up a new analytics pipeline today, INP is the number you should actually be watching and alerting on. FID only measured the delay before one interaction; INP looks at every interaction on the page and reports something closer to a worst-case, which is a meaningfully better signal for whether your app feels janky under real use. If you're building a dashboard from scratch, I'd log FID for completeness but build your alerting thresholds around INP.

Handling Metrics by Name

In practice, you rarely want to treat every metric identically — TTFB probably goes to your infrastructure monitoring, while CLS might feed a UX-focused dashboard. The name field lets you branch:

// app/components/web-vitals.tsx
"use client";

import { useReportWebVitals } from "next/web-vitals";

type ReportWebVitalsCallback = Parameters<typeof useReportWebVitals>[0];

const handleWebVitals: ReportWebVitalsCallback = (metric) => {
  switch (metric.name) {
    case "FCP": {
      // handle FCP results
      break;
    }
    case "LCP": {
      // handle LCP results
      break;
    }
    case "CLS": {
      // handle CLS results
      break;
    }
    case "INP": {
      // handle INP results
      break;
    }
    default: {
      // TTFB, FID, or anything else
      break;
    }
  }
};

export function WebVitals() {
  useReportWebVitals(handleWebVitals);
}

Deriving the callback's type from Parameters<typeof useReportWebVitals>[0] instead of writing out a hand-rolled Metric type is a small trick worth stealing — it means if Next.js ever changes the shape of the metric object in a future release, TypeScript will flag every place your switch statement needs updating, rather than silently drifting out of sync with an interface you copy-pasted once and forgot about.

Sending Results to an External System

Console-logging metrics is fine for local development, but the entire point of this exercise is usually to get the data off the user's machine and into something you can query later. The standard pattern uses navigator.sendBeacon, falling back to fetch:

function postWebVitals(metric) {
  const body = JSON.stringify(metric);
  const url = "https://example.com/analytics";

  if (navigator.sendBeacon) {
    navigator.sendBeacon(url, body);
  } else {
    fetch(url, { body, method: "POST", keepalive: true });
  }
}

The reason sendBeacon is preferred over a plain fetch here is subtle but important: sendBeacon is specifically designed to survive the page unloading. If a user clicks a link and navigates away right as your CLS measurement finalizes, a normal fetch call can get cancelled mid-flight by the browser tearing down the page context. sendBeacon queues the request at the browser level and guarantees it gets sent even after the page is gone, which matters enormously for metrics like CLS and LCP that are frequently only "final" right as the user is leaving the page (a bounce, essentially). The keepalive: true option on the fetch fallback is doing similar work for browsers that lack sendBeacon support, but sendBeacon should always be your first choice when it's available.

Wiring This Into Google Analytics

If you already have Google Analytics running via gtag, the pattern for feeding it Web Vitals data looks like this:

useReportWebVitals((metric) => {
  window.gtag("event", metric.name, {
    value: Math.round(
      metric.name === "CLS" ? metric.value * 1000 : metric.value,
    ),
    event_label: metric.id,
    non_interaction: true,
  });
});

Two details here trip people up if they don't read closely:

The CLS multiplication by 1000. Google Analytics events require integer values, but CLS is reported as a small decimal (typically somewhere between 0 and 0.25 for a well-behaved page). Multiplying by 1000 converts it into an integer-friendly range without losing meaningful precision. If you skip this, Math.round will flatten most real-world CLS scores down to 0, and your dashboard will falsely report a perfect layout-stability score.

non_interaction: true. Without this flag, Google Analytics counts the event as a user interaction, which drags your bounce rate down artificially — a visitor who loaded one page and left would no longer register as a "bounce" purely because a Web Vitals beacon fired in the background. This is easy to miss and produces genuinely misleading analytics if you get it wrong; I've seen teams spend hours confused about why their bounce rate suddenly dropped after adding Web Vitals tracking, only to trace it back to this missing flag.

The Duplicate-Reporting Trap

The single most important line in the official docs is easy to skim past: "ensure that the callback function reference does not change." Here's what that means in practice, and why it matters.

// Don't do this
export function WebVitals() {
  useReportWebVitals((metric) => {
    console.log(metric);
  });
}

Defining the callback as an inline arrow function means a brand-new function is created on every render of WebVitals. Internally, the hook treats a changed callback reference as a signal to re-subscribe, which under certain render patterns (fast refresh during development, or a parent re-rendering for unrelated reasons) can result in the same metric being reported more than once, or listeners piling up rather than being cleanly replaced. The fix is straightforward — define the callback outside the component, or wrap it in useCallback with an empty dependency array if it genuinely needs to close over something stable:

const logWebVitals = (metric) => {
  console.log(metric);
};

export function WebVitals() {
  useReportWebVitals(logWebVitals);

  return null;
}

This is a one-line fix, but it's the kind of bug that's nearly invisible in development (you might just see a metric logged twice and shrug it off) and genuinely damaging in production analytics, where duplicate LCP or CLS reports silently inflate your averages and make your real performance look worse than it is.

When a Managed Service Is the Better Call

Rolling your own reporter is the right choice when you need full control over the destination — your own data warehouse, a specific APM vendor, a custom internal dashboard. But it's worth being honest about the tradeoff: you're now responsible for the ingestion endpoint, storage, aggregation, and any dashboarding on top of it. That's not a huge lift for a side project logging to a spreadsheet, but it adds up for a team that wants percentile breakdowns (p75 LCP is the standard Google looks at, not the average), geographic segmentation, or historical trend lines.

If you're deploying on Vercel, their managed Speed Insights product wires directly into this same Web Vitals pipeline with essentially zero setup — you get percentile dashboards, device/geography breakdowns, and historical comparisons without writing a single ingestion endpoint. It's not free at meaningful scale, and it only covers what Vercel chooses to expose, but for a team that just wants an honest picture of real-user performance without building analytics infrastructure, it's a reasonable default. If you're self-hosting or deployed elsewhere, third-party alternatives like Plausible, PostHog, or a straightforward custom endpoint fed by useReportWebVitals all work fine — the hook is deliberately vendor-neutral so none of this locks you in.

Why Percentiles Beat Averages

If you do build your own ingestion pipeline, resist the temptation to just average incoming values and call it a day. Averages are a poor way to represent performance data because a small number of very slow outliers — a user on a congested train network, an old device thermal-throttling mid-session — can be masked by a much larger number of fast, unremarkable page loads. A page that's fast for 95% of visitors and catastrophically slow for the remaining 5% can still post a perfectly respectable average, while genuinely failing a meaningful chunk of real users.

This is why Google's own Core Web Vitals thresholds are defined at the 75th percentile (p75), not the mean. Practically, that means: if you're storing raw metric values, store enough of them (or a reasonable sample) to compute percentiles after the fact, rather than pre-aggregating into a single running average server-side. A simple approach that works well even at moderate scale is to write each metric as its own row (metric name, value, rating, timestamp, page path) into whatever datastore you're already using, and compute p75/p95 in your querying layer rather than at write time. It's more storage than a running average, but it's the only representation that lets you answer the question that actually matters: "how bad is the experience for my worst-served users," not just "what's typical."

Building a Minimal Ingestion Endpoint

If you'd rather not depend on a third-party analytics vendor at all, pairing useReportWebVitals with a Next.js Route Handler gives you a complete, self-hosted pipeline in well under fifty lines of code. Here's a minimal version that accepts a beacon payload and writes it somewhere durable:

// app/api/vitals/route.ts
import { NextRequest, NextResponse } from "next/server";

export async function POST(request: NextRequest) {
  const metric = await request.json();

  // Validate the shape before trusting it — this endpoint is public
  if (typeof metric.name !== "string" || typeof metric.value !== "number") {
    return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
  }

  await fetch(process.env.METRICS_INGEST_URL!, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      ...metric,
      path: request.headers.get("referer") ?? "unknown",
      timestamp: Date.now(),
    }),
  });

  return NextResponse.json({ ok: true });
}

Point your client-side reporter at it:

function postWebVitals(metric) {
  const body = JSON.stringify(metric);
  const url = "/api/vitals";

  if (navigator.sendBeacon) {
    navigator.sendBeacon(url, body);
  } else {
    fetch(url, { body, method: "POST", keepalive: true });
  }
}

A few practical notes if you go this route: sendBeacon always issues a POST with a Content-Type of text/plain rather than application/json, regardless of what you pass — most frameworks (Next.js included) will still parse the JSON body correctly since request.json() doesn't actually check the header, but it's worth knowing if you add stricter content-type validation later. Also, because this endpoint is publicly reachable and hit by every page load, keep it deliberately lightweight — avoid synchronous database writes on the request path where possible, and consider batching writes to your actual datastore (via a queue, or a scheduled job) rather than hitting it once per beacon. A single popular page generating six metrics per visit, multiplied across real traffic, adds up faster than it looks on paper.

Common Mistakes Worth Avoiding

Marking the whole layout as a Client Component just to add analytics. Always isolate the hook in its own small component, as shown above, rather than adding 'use client' to layout.js directly.

Defining the callback inline. As covered above, this risks duplicate reporting. Hoist it outside the component or memoize it.

Trusting numbers measured in development. The dev server includes extra instrumentation, hot-reload overhead, and unminified code, all of which distort every timing metric. Always validate real performance numbers against a production build (next build && next start), ideally measured from a real device on a real network rather than your development machine on fiber.

Alerting on FID instead of INP. As discussed, FID is deprecated as a ranking signal. Build new dashboards and alerts around INP.

Forgetting non_interaction: true when wiring into Google Analytics. This one is invisible until someone notices your bounce rate doing something inexplicable.

Not batching or debouncing outbound requests. If you're sending each metric as its own sendBeacon or fetch call and a page fires all six metrics in quick succession, that's six outbound requests just to measure performance — a small irony worth avoiding on high-traffic pages. Consider batching metrics client-side over a short window before sending, if your ingestion endpoint can accept batched payloads.

Key Takeaways

ConcernWhat to do
Basic reportinguseReportWebVitals from next/web-vitals, isolated in its own Client Component
Global monitoring/error tracking setupinstrumentation-client.js, runs before the app starts
Avoiding duplicate reportsKeep the callback reference stable — define it outside the component or memoize it
Which responsiveness metric to trustINP, not FID (FID is deprecated but still reported)
Sending data off-devicenavigator.sendBeacon, falling back to fetch with keepalive: true
Google Analytics integrationMultiply CLS by 1000, and set non_interaction: true
Full-service dashboards without infrastructure workVercel Speed Insights (if deployed on Vercel) or a third-party analytics provider

Measuring performance in Next.js doesn't require a heavy SDK or a vendor lock-in decision made on day one. useReportWebVitals gives you the same underlying data a managed service would use, in a form you can route anywhere — and understanding its few sharp edges (the callback-stability trap, the CLS scaling quirk, the FID-versus-INP distinction) is enough to build a reporting pipeline you can actually trust.

Tags :
Share :

Related Posts

Can Next.js Be Used with GraphQL?

Can Next.js Be Used with GraphQL?

Next.js and GraphQL are two powerful technologies that have gained significant traction in the web development community. Next.js, a React-based fram

Dive Deeper
How does Next.js differ from Create React App?

How does Next.js differ from Create React App?

In the world of modern web development, React.js has emerged as a dominant force due to its flexibility, performance, and extensive ecosystem. Two po

Dive Deeper
How does Next.js handle image optimization?

How does Next.js handle image optimization?

In modern web development, image optimization plays a critical role in enhancing user experience and improving site performance. Large, unoptimized i

Dive Deeper