DEV Community

Cover image for Fix "lcp" not working in production
Mahdi BEN RHOUMA
Mahdi BEN RHOUMA

Posted on Originally published at iloveblogs.blog

Fix "lcp" not working in production

In dev (npm run dev) the LCP metric shows up in the browser console, because the dev overlay injects its own logger. Run npm run build && npm start — or deploy — and the metric disappears: the Largest Contentful Paint column in Vercel's dashboard (or your custom analytics) stays blank while the site otherwise works fine. The behaviour reproduces on every environment where the production build runs — Vercel, Netlify, or a self-hosted Node server.

I ran into this pattern while debugging a different production issue (the infamous "cookies() should be awaited" error): many production-only bugs in Next.js stem from missing hooks that are only executed in a built bundle. One of those hooks is the reportWebVitals export — silent in development, crucial in production. When it's absent, LCP never reaches Vercel or any custom analytics endpoint.

The reportWebVitals export is the entire collection pipeline

Next.js collects Core Web Vitals (LCP, CLS, INP, etc.) only when the application exports a function named reportWebVitals (Pages Router) or calls the useReportWebVitals hook (App Router). The framework calls this function with a metric object for each measurement. If the export is missing, or if the function filters out LCP (for example, by returning early for non-LCP metrics), the data never leaves the client. Consequently, any analytics service that relies on the metric — Vercel's built-in dashboard, Google Analytics, or a custom endpoint — receives nothing.

The relevant code path lives in Next.js's next/dist/shared/lib/router-vitals module. When the client boots, it checks whether the app exposes a reportWebVitals function. If it does, it registers a listener that forwards each metric to that function. If not, the listener is never attached, and the metric stream ends silently:

// node_modules/next/dist/compiled/next-web-vitals/index.js (simplified)
if (typeof appModule.reportWebVitals === 'function') {
  onMetric(metric => appModule.reportWebVitals(metric));
}
Enter fullscreen mode Exit fullscreen mode

Because the check is performed after the production bundle is generated, any change to _app requires a full rebuild to take effect — hot-module replacement in dev mode masks the problem.

There is no next.config.js flag for this

Worth stating up front, because it saves a lot of searching: there is no experimental.webVitals config flag in next.config.js — it never existed. Web-vitals collection has always been driven solely by the reportWebVitals export (Pages Router) / useReportWebVitals hook (App Router). Don't go looking for a flag to flip — the fix is always to add or fix the export.

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  // No web-vitals flag exists. Collection is driven by the reportWebVitals
  // export in _app.js — just make sure that file is present and the
  // function does not early-return on non-LCP metrics.
  // Other project-specific config …
};

module.exports = nextConfig;
Enter fullscreen mode Exit fullscreen mode

Wiring the export in pages/_app.js

Add a reportWebVitals export that forwards all metrics — and make sure it does not discard LCP:

// pages/_app.js
import '../styles/globals.css';
import { useEffect } from 'react';

function MyApp({ Component, pageProps }) {
  // Your usual app wrapper
  return <Component {...pageProps} />;
}

// Exported function that Next.js will call for each Web Vitals metric
export function reportWebVitals(metric) {
  // Send the metric to your analytics endpoint
  const body = JSON.stringify(metric);
  // Use the browser's fetch API – it works both client-side and server-side
  if (navigator.sendBeacon) {
    navigator.sendBeacon('/api/web-vitals', body);
  } else {
    fetch('/api/web-vitals', {
      method: 'POST',
      keepalive: true,
      headers: { 'Content-Type': 'application/json' },
      body,
    });
  }

  // Optional: log LCP to the console for quick verification
  if (metric.name === 'LCP') {
    console.log('🔍 LCP metric:', metric);
  }
}

export default MyApp;
Enter fullscreen mode Exit fullscreen mode

This resolves the issue because Next.js now has a concrete listener that forwards every metric, including LCP, to an endpoint you control.

The receiving endpoint

The export above posts to /api/web-vitals, so create that route with a handler that logs the payload:

// pages/api/web-vitals.js
export default async function handler(req, res) {
  if (req.method === 'POST') {
    const metric = req.body;
    // Here you could forward the metric to an external service like GA, Mixpanel, etc.
    console.log('Received Web Vital:', metric);
    res.status(200).json({ received: true });
  } else {
    res.setHeader('Allow', ['POST']);
    res.status(405).end(`Method ${req.method} Not Allowed`);
  }
}
Enter fullscreen mode Exit fullscreen mode

From here the sequence is: paste the reportWebVitals export into pages/_app.js (create it if it doesn't exist, matching the /api/web-vitals path), create the API route, then run a fresh production build — remember, the export only takes effect after npm run build.

Watching the metric arrive in a local production build

npm run build
npm start
Enter fullscreen mode Exit fullscreen mode
> next build
Compiled successfully
> next start
ready - started server on http://localhost:3000
Enter fullscreen mode Exit fullscreen mode

Open http://localhost:3000 in Chrome, open DevTools → Console, and you should see something like:

🔍 LCP metric: {name:"LCP",value:1234,delta:1234,id:"v1-162...",entries:[...],navigationType:"reload"}
Received Web Vital: {"name":"LCP","value":1234,"delta":1234,"id":"v1-162...","entries":[...],"navigationType":"reload"}
Enter fullscreen mode Exit fullscreen mode

If the console shows the LCP object and the API route logs the same payload, the metric is now being captured and forwarded. Check Vercel's analytics dashboard (or your external analytics) after a few minutes; the LCP column should populate with real numbers.

Other metrics arrive but LCP still doesn't: look for a filter

When CLS or INP show up but LCP stays empty, the export exists — something inside it is discarding LCP. Two shapes of the same mistake:

An explicit inverted guard someone added while debugging:

if (metric.name !== 'LCP') return;
Enter fullscreen mode Exit fullscreen mode

(That one keeps only LCP — its mirror image, if (metric.name === 'LCP') return;, drops it.) Remove the early return, or ensure the function processes all metrics.

The subtler version: the hook was written to handle a single metric — say CLS — and silently returns for everything else. Open pages/_app.js (or the App Router useReportWebVitals call) and confirm the fetch/sendBeacon call runs for every metric.name, not just CLS. Remove any early return that skips other metric types, then rebuild.

Keeping the export from disappearing again

Next.js treats reportWebVitals as an opt-in hook, and there is no built-in ESLint rule that enforces its presence — a refactor of _app.js can drop it without any warning. Guard it yourself with a tiny CI check:

// scripts/check-web-vitals.js — run in CI
const { execSync } = require('child_process')
const fs = require('fs')
const path = require('path')

// App Router: look for useReportWebVitals in the root layout's tree.
// Pages Router: look for `export function reportWebVitals` in pages/_app.*.
const appFile = path.join('pages', '_app.js')
if (fs.existsSync(appFile)) {
  const src = fs.readFileSync(appFile, 'utf8')
  if (!/export\s+function\s+reportWebVitals/.test(src)) {
    console.error('pages/_app.js is missing `export function reportWebVitals`')
    process.exit(1)
  }
}
console.log('reportWebVitals present')
Enter fullscreen mode Exit fullscreen mode

You can also write a simple Jest test that imports the compiled _app and asserts that typeof app.reportWebVitals === 'function'. Running this test in CI ensures the hook never disappears after refactors. For what to do with the metric once it's flowing — thresholds, attribution, dashboards — see How to implement LCP end to end.

Related


Originally published at https://www.iloveblogs.blog

Top comments (0)