How to implement LCP end to end
Learn how to instrument Largest Contentful Paint (LCP) in a Next.js project, send the metric to your analytics provider, and verify that the data is accurate.
Your performance dashboard has an LCP chart, and it's blank. Nothing errors, nothing warns — the metric simply never arrives. In a Next.js app the cause is almost always missing web‑vitals instrumentation: the browser measures Largest Contentful Paint on every load, but unless a listener forwards it to your backend, the number dies inside the browser. This post wires up the full path — observer, transport, API receiver — and then verifies each hop.
A blank LCP chart and a silent console#
When the instrumentation is missing, the console stays quiet and your analytics endpoint receives nothing. A typical “broken” run looks like this:
> npm run dev
> my-next-app@0.1.0 dev
> next dev
ready - started server on http://localhost:3000You open the page, interact with it, and never see a line such as LCP: 1.23s in the terminal or network tab. The problem surfaces after you deploy to Vercel because the production build also lacks the listener, so your performance dashboard shows a blank LCP chart.
It happens when you:
- launch the app with
npm run devornext start, - navigate to any route that renders above‑the‑fold content,
- expect the metric to appear in your custom analytics dashboard.
The behavior is identical on local development, staging, and production because the missing code lives in the shared pages/_app.js file.
Where the observer has to run#
Next.js ships a tiny helper called next/web-vitals that abstracts the browser’s PerformanceObserver API. If you never import or call this helper, the browser never sends the LCP value to your JavaScript. The result is a silent metric that never reaches your backend.
Two things compound the issue:
- Server‑side rendering hides the client‑side observer. When the page is rendered on the server, there is no
windowobject, so the observer can only be attached in the client bundle. If you place the observer in a file that never runs on the client (e.g., a server‑only API route), it never fires. - Turbopack’s aggressive tree‑shaking can drop unused code. If you import
next/web-vitalsbut never reference the exported function, Turbopack removes it from the final bundle, leaving the observer absent in production. See the Next.js Turbopack stuck / disable guide for more details.
The relevant code path in Next.js looks like this (simplified):
// node_modules/next/dist/compiled/web-vitals/index.js
export function onCLS(onReport) { /* registers CLS observer */ }
export function onLCP(onReport) { /* registers LCP observer */ }If onLCP is never called, the browser never records the metric.
Watch the function name if you're following an older tutorial. The web-vitals npm package renamed its getCLS/getFID/getLCP functions to onCLS/onFID/onLCP in v3.0.0 (August 2022) — the get* names no longer exist in the package. If your code (or a guide you copied from) imports getLCP, the import fails outright on any web-vitals version from the last few years. Everything below uses the current onLCP name.
There is a timing constraint on top of that: the browser only emits LCP once per page load. If your code registers the observer after the paint has already happened, the metric is lost. Placing the onLCP call inside a useEffect that runs on the first client render guarantees the observer is attached early enough.
Wiring onLCP into pages/_app.js#
Add a small reportWebVitals function to pages/_app.js. The function receives a metric object, extracts the name and value, and forwards it to an API route you control. The API route can then forward the data to Google Analytics, Segment, or any custom endpoint.
// pages/_app.js
import '../styles/globals.css';
import { useEffect } from 'react';
import { onLCP } from 'web-vitals';
function reportWebVitals(metric) {
// Convert the metric value to milliseconds for consistency
const payload = {
name: metric.name,
value: Math.round(metric.value),
id: metric.id,
page: window.location.pathname,
};
// Send the metric to our analytics endpoint
fetch('/api/analytics', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}).catch(console.error);
}
// Attach the observer once the component mounts
export default function MyApp({ Component, pageProps }) {
useEffect(() => {
// onLCP registers a PerformanceObserver for LCP
onLCP(reportWebVitals);
}, []);
return <Component {...pageProps} />;
}That single change addresses the cause because it guarantees that the LCP observer runs in the client bundle, survives Turbopack optimisation, and always posts the metric to your backend.
To apply it in your project:
- Open
pages/_app.js. If the file does not exist, create it at the project root underpages/. - Add the
import { onLCP } from 'web-vitals';line at the top. (onLCPcomes from the Googleweb-vitalsnpm package. Next.js's ownuseReportWebVitalshook fromnext/web-vitalsis the simpler route for most apps — it works in both the Pages and App Router and needs no manualuseEffect— but wiring the raw observer yourself, as below, gives finer control over timing and fallback handling.) - Paste the
reportWebVitalsfunction shown above, adjusting the endpoint URL if you use a different path. - Inside the
MyAppcomponent, add auseEffectthat callsonLCP(reportWebVitals). - Save the file and restart the dev server (
npm run dev). The observer will now fire on the first paint of any page.
Receiving the metric: pages/api/analytics.js#
You also need a tiny API route to accept the POST request:
// pages/api/analytics.js
export default async function handler(req, res) {
if (req.method !== 'POST') {
res.setHeader('Allow', ['POST']);
return res.status(405).end('Method Not Allowed');
}
const metric = req.body;
// Forward to your analytics provider – here we just log it
console.log('Received web vital:', metric);
// Example: send to an external service
// await fetch('https://example.com/collect', {
// method: 'POST',
// headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify(metric),
// });
res.status(200).json({ status: 'ok' });
}The API route is optional if you already have a logging solution, but keeping it explicit makes the end‑to‑end flow clear.
Watching the metric arrive#
Run the development server:
npm run devOpen the site in Chrome, open DevTools → Network, and filter for analytics. Reload the page. You should see a POST request similar to:
POST /api/analytics HTTP/1.1
Content-Type: application/json
{"name":"LCP","value":1234,"id":"v2-1623456789-0","page":"/"}At the same time, the server console will output:
Received web vital: { name: 'LCP', value: 1234, id: 'v2-1623456789-0', page: '/' }If you see both the network request and the console log, the instrumentation works. Open the page a few times and watch the value change; it should reflect the time (in milliseconds) it took for the largest contentful element to appear.
If you still don’t see any request, double‑check that:
- The
useEffectruns only on the client (no SSR errors in the console). - The fetch URL matches the API route (
/api/analytics). - Turbopack isn’t stripping
onLCP. If you suspect that, try disabling Turbopack temporarily as described in the Next.js Turbopack disable guide.
Slow connections: the observer that never fires#
On a very slow network the LCP observer may fire after the page has already been unmounted, causing the fetch to be aborted. To guard against this, add a timeout fallback:
useEffect(() => {
const timeout = setTimeout(() => {
// Send a placeholder if the observer never fires
reportWebVitals({ name: 'LCP', value: 0, id: 'timeout' });
}, 15000); // 15 seconds
onLCP(metric => {
clearTimeout(timeout);
reportWebVitals(metric);
});
}, []);Client-side navigations and duplicate LCP events#
If you navigate client‑side (using next/link) the observer may emit a new LCP for each navigation. To avoid double‑counting, filter out metrics that arrive after the first one per page:
let reported = false;
function reportWebVitals(metric) {
if (reported) return;
reported = true;
// send as before
}There is no next.config.js flag for this#
Worth stating explicitly, because it's a common thing to go searching for: there is no experimental.webVitals (or similar) config flag in next.config.js — it has never existed. Web-vitals collection is driven entirely by whichever listener you attach — the raw onLCP wiring above, or the framework-native reportWebVitals export (Pages Router) / useReportWebVitals hook from next/web-vitals (works in both Pages and App Router, per Next.js's docs). If LCP is missing, the fix is always in the listener code, never in project config.
Other metrics arrive but LCP still doesn't: look for a filter#
If you're using the framework-native reportWebVitals export instead of the raw onLCP observer above, a different failure mode shows up: CLS or INP data appears in your analytics, but LCP stays empty even though the export clearly exists. That means something inside the function is discarding LCP specifically. Two shapes of the same mistake:
An explicit inverted guard left over from earlier debugging:
if (metric.name !== 'LCP') return;That one keeps only LCP — its mirror image, if (metric.name === 'LCP') return;, silently drops it. Or the subtler version: the handler was originally written for a single metric — say CLS — and returns early for everything else without anyone noticing LCP got swept up in the exclusion. Open the handler and confirm the fetch/sendBeacon call runs for every metric.name, not just the one it was first written for.
Keeping the instrumentation from disappearing again#
There is no built-in ESLint rule that enforces a reportWebVitals export, so guard it with a tiny CI script instead:
// scripts/check-web-vitals.js — run in CI before deploy
const fs = require('fs')
if (fs.existsSync('pages/_app.js')) {
const src = fs.readFileSync('pages/_app.js', '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')You can also write a simple Jest test that renders the app with @testing-library/react and asserts that a network request to /api/analytics occurs within 5 seconds. Automated testing catches the omission before it lands in production.
Further reading: Next.js Images Not Loading from public/static Folder: Fix, Fix next/image Hostname Not Configured in Next.js.
Frequently Asked Questions
Still stuck? Paste your error.
Paste the error message or stack trace — we check it against every fix and tool on the site.
One email a month — the latest explained errors
What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.
Continue Reading
How to set the next/image component to 100% height
If your <Image / component ignores height: 100% and appears too small or misaligned, the issue is almost always that the parent container lacks an explicit
Fix next/image Hostname Not Configured in Next.js
next/image strictly allow-lists remote hosts. The fix is images.remotePatterns in next.config — matched exactly on protocol, hostname, port, and pathname. Get one wrong (http vs https, a subdomain, a missing port) and it still blocks.
Next.js Images Not Loading from public/static Folder: Fix
Which URL serves a file from public/, why next/image answers 400 instead of 404, and what basePath and a production server change.


