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.
TL;DR#
If you're not seeing any LCP data in your analytics, the cause is usually missing web‑vitals instrumentation in your Next.js app. Fix it by adding a small listener in pages/_app.js that forwards the metric to your endpoint.
If that doesn't work, scroll to verify the fix — there are two common variants this guide also covers.
What you'll see#
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:3000
You 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.
Root cause#
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 post about Disable Turbopack for Next.js Production Build 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 getCLS(onReport) { /* registers CLS observer */ }
export function getLCP(onReport) { /* registers LCP observer */ }
If getLCP is never called, the browser never records the metric.
The fix#
Add a tiny 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 { getLCP } from 'next/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(() => {
// getLCP registers a PerformanceObserver for LCP
getLCP(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.
Step by step#
- Open
pages/_app.js. If the file does not exist, create it at the project root underpages/. - Add the
import { getLCP } from 'next/web-vitals';line at the top. - Paste the
reportWebVitalsfunction shown above, adjusting the endpoint URL if you use a different path. - Inside the
MyAppcomponent, add auseEffectthat callsgetLCP(reportWebVitals). - Save the file and restart the dev server (
npm run dev). The observer will now fire on the first paint of any page.
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.
Verify the fix#
Run the development server:
npm run dev
Open 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
getLCP. If you suspect that, try disabling Turbopack temporarily as described in Fix Next.js 16 Turbopack Production Build Failures.
Variant A — LCP never fires on slow connections#
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
getLCP(metric => {
clearTimeout(timeout);
reportWebVitals(metric);
});
}, []);
Variant B — Multiple LCP events in a single session#
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
}
Why this happens (and how to avoid it next time)#
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. By placing the getLCP call inside a useEffect that runs on the first client render, you guarantee the observer is attached early enough. Adding a lint rule that flags missing next/web-vitals imports in pages/_app.js can prevent regressions. For example, extend your ESLint config:
// .eslintrc.js
module.exports = {
// …
rules: {
'no-missing-web-vitals': ['error', { files: ['pages/_app.js'] }],
},
};
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.
Related#
Frequently Asked Questions
One email a month — no fluff
RLS gotchas, Next.js cache debugging, and the one Supabase setting that bit me last month.
Continue Reading
Fix "lcp" not working in production
When LCP data never appears in your analytics, a missing reportWebVitals export is usually to blame. Follow these steps to fix it.
Next.js Performance Optimization: 10 Essential Techniques
Essential Next.js performance optimization techniques. Learn image optimization, caching, bundle splitting, and how to improve Core Web Vitals.
Next.js Turbopack Stuck Compiling: 9 Fixes for Dev and Production Builds
Turbopack stuck on compiling in Next.js 15? Learn the exact causes and 5 proven fixes to get your dev server running in minutes.
Browse by Topic
Find stories that matter to you.
