Table of Contents
To pass Core Web Vitals on a Next.js site, render above-the-fold content on the server, load the hero image and fonts early with next/image and next/font, keep client JavaScript to a minimum with Server Components, and reserve space for everything that loads late. Google’s “good” thresholds are LCP ≤ 2.5 s, INP ≤ 200 ms and CLS ≤ 0.1, measured at the 75th percentile of real visits. Most failing sites miss them for a few repeatable reasons, each with a framework-level fix.
Table of Contents
Measure first: field data vs lab data
Core Web Vitals are judged on field data: measurements from real Chrome users, collected in the Chrome User Experience Report (CrUX). Search Console and the top of PageSpeed Insights both use CrUX, which covers a rolling 28-day window.
Lab data, from Lighthouse or the Performance panel in Chrome DevTools, runs a single simulated load on a fixed device and network profile. It is ideal for debugging, but it is not what Google uses to assess your pages. It also cannot measure INP during a page load, because INP needs real interactions; Total Blocking Time is the closest lab proxy.
| Source | Type | Best used for |
| Search Console (Core Web Vitals report) | Field (CrUX) | Which URL groups fail, and on mobile or desktop |
| PageSpeed Insights | Field + lab | Per-URL field data plus a Lighthouse run with diagnostics |
| Lighthouse / DevTools Performance panel | Lab | Finding the LCP element, long tasks and layout shifts locally |
useReportWebVitals + your analytics | Field (your own RUM) | Per-route, per-release data without waiting 28 days |
Collect your own field data with useReportWebVitals
Next.js exposes a useReportWebVitals hook from next/web-vitals. Put it in a small Client Component and render that once in the root layout.
// app/_components/web-vitals.tsx
'use client';
import { useReportWebVitals } from 'next/web-vitals';
export function WebVitals() {
useReportWebVitals((metric) => {
const body = JSON.stringify({
name: metric.name, // 'LCP' | 'INP' | 'CLS' | 'FCP' | 'TTFB'
value: metric.value,
id: metric.id,
path: window.location.pathname,
});
navigator.sendBeacon?.('/api/vitals', body);
});
return null;
}
// app/layout.tsx: render <WebVitals /> inside <body>
Segment the values by route and device, and read the 75th percentile, not the average.
Fixing LCP (Largest Contentful Paint)
LCP measures when the largest image or text block in the viewport renders, usually a hero image or headline. Split it into server response time (TTFB), resource load delay, download time and render delay; each has a different fix.
Load the hero image early with next/image
next/image lazy-loads images by default, which is right for images further down the page and wrong for the LCP image. Mark the hero with priority so Next.js preloads it and skips lazy loading. Give it an accurate sizes value so the browser picks a sensibly sized file from the generated srcset instead of the largest one.
import Image from 'next/image';
import hero from '@/public/hero.jpg';
export function Hero() {
return (
<Image
src={hero}
alt="Students comparing colleges"
priority
sizes="(max-width: 768px) 100vw, 50vw"
/>
);
}
Use priority on one or two images per page, not all of them. The prop naming has changed in recent releases, so check the docs for your version. Avoid CSS background-image heroes: the browser discovers them late.
Self-host fonts with next/font
When the LCP element is text, font loading decides when it paints. next/font downloads Google Fonts (or your local files) at build time and serves them from your own domain, so there is no extra connection to a third-party font host. It also preloads the font files and uses font-display: swap by default.
Import the font once in the root layout, apply its className to <html>, and load only the subsets and weights you use. A variable font is usually lighter than several static weights.
Render above-the-fold content on the server
A very common LCP failure is a page that fetches its main content in a useEffect. The headline waits for HTML, JavaScript, hydration and an API call. With the App Router, fetch the data in a Server Component instead so the content arrives in the initial HTML:
// app/courses/[slug]/page.tsx
export default async function CoursePage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const course = await getCourse(slug); // runs on the server
return (
<article>
<h1>{course.title}</h1>
<p>{course.summary}</p>
</article>
);
}
(In older versions params is a plain object rather than a Promise; follow the signature your version expects.) Moving from a client-rendered React app? Our guide on migrating from React to Next.js covers the rest.
Stream the slow parts
If one section depends on a slow API, such as reviews or recommendations, do not make the whole response wait. Wrap it in <Suspense> (or add a loading.tsx for the route segment) so Next.js streams the fast shell, including the LCP element, first and fills in the slow section when it is ready. Make the fallback the same size as the final content, or you swap an LCP problem for a CLS problem.
Cache aggressively and serve from a CDN
A slow TTFB puts a floor under LCP. Pages that do not change per user should be static or use ISR: set export const revalidate = 3600 on the route, or pass { next: { revalidate: 3600 } } to individual fetch calls, and pre-build known paths with generateStaticParams. Caching defaults for fetch have changed between major versions, so check whether your data is actually being cached rather than assuming it is. Put a CDN in front as well. We compare the rendering options in SSG vs SSR in Next.js, and cover CDN setup in deploying a Next.js application on AWS.
Watch for anything that silently makes a route dynamic, such as reading cookies() or headers() in a shared layout. The build output shows which routes are static.
Fixing INP (Interaction to Next Paint)
INP measures how quickly the page responds visually to clicks, taps and key presses. Poor INP usually means a busy main thread: too much JavaScript to hydrate, heavy event handlers or third-party scripts.
Ship less JavaScript with Server Components
App Router components are Server Components by default and send no component JavaScript to the browser. 'use client' marks a boundary: that file and everything it imports join the client bundle, so a directive at the top of a layout or large page pulls in the whole tree.
Push the boundary down to the leaves: keep pages, layouts and static content on the server, and make only the interactive widget (an add-to-cart button, a filter panel) a Client Component. Server Components can also be passed as children into a Client Component, such as a tab switcher wrapping server-rendered panels, without becoming client code themselves.
Defer heavy client code with dynamic imports
Load charts, editors, maps and modals on demand with next/dynamic (ssr: false only works inside a Client Component):
'use client';
import dynamic from 'next/dynamic';
const PriceChart = dynamic(() => import('./price-chart'), {
ssr: false,
loading: () => <div className="h-80" />, // reserve the chart's height
});
Control third-party scripts with next/script
Tag managers, chat widgets and ad scripts share your main thread. Load them with next/script and choose the latest strategy you can live with:
beforeInteractive: loads before hydration; only for scripts that must run first, such as a consent manager.afterInteractive(default): loads after hydration; fine for analytics.lazyOnload: loads in idle time; use it for chat widgets and social embeds.
Then audit what your tag manager injects; it often loads scripts nobody on the team reviewed.
Break up long tasks
Any task over 50 ms is a long task and delays input handling. Record the interaction in DevTools, find the long tasks after the click, then:
- Wrap expensive state updates in
useTransitionso input stays responsive. - Use
useDeferredValuefor search-as-you-type results. - Show visual feedback first, then do the heavy work, yielding to the main thread in between.
- Filter or sort large datasets on the server, or paginate.
- Virtualise long lists instead of rendering thousands of DOM nodes.
Fixing CLS (Cumulative Layout Shift)
CLS measures unexpected movement of visible content. The fixes are mostly about reserving space.
Give every image dimensions
next/image requires width and height (inferred automatically for static imports) or the fill prop, so the browser can reserve the right box before the file loads. With fill, the parent must be positioned and have a size, for example with a CSS aspect-ratio. Watch for plain <img> tags in CMS rich text.
Match font fallbacks
With font-display: swap, text reflows when the web font replaces the fallback. next/font generates a fallback font face with adjusted metrics (size-adjust and related descriptors) so both take up nearly the same space. Keep that on, and remove leftover <link> or @import font loads.
Reserve space for embeds, ads and late content
- Give ad slots, video embeds and iframes a
min-heightoraspect-ratiomatching their content. - Make
Suspenseandloading.tsxskeletons the same size as the final content. - Show cookie banners and promo bars as overlays.
- Use CSS media queries, not
window.innerWidth, to switch layouts.
Bundle analysis: find what you are shipping
@next/bundle-analyzer produces a treemap of each client bundle (it targets webpack builds; check the docs for the Turbopack equivalent):
// next.config.ts
import bundleAnalyzer from '@next/bundle-analyzer';
const withBundleAnalyzer = bundleAnalyzer({ enabled: process.env.ANALYZE === 'true' });
export default withBundleAnalyzer({ /* your config */ });
// ANALYZE=true npm run build
Typical findings: a whole utility or icon library imported for a few functions, or a server SDK pulled in by a 'use client' import. Import server-only in modules that must never reach the browser so the build fails if they do.
Middleware cost
Middleware runs before every request it matches, even for pages otherwise served from cache. A database lookup or external API call inside it adds directly to TTFB, and so to LCP. (Recent releases have renamed the middleware file convention to proxy; the cost model is the same.)
- Use the
matcherconfig to limit it to the paths that need it, excluding_next/static,_next/imageand public assets. - Keep it to cheap checks such as reading a cookie or redirecting; do data access in the page or layout.
Diagnosis table: symptom, likely cause, Next.js fix
| Symptom | Likely cause | Next.js fix |
| High LCP, high TTFB | Dynamic rendering per request; slow middleware; no CDN | Static rendering or revalidate; narrow middleware matcher; CDN |
| High LCP, hero image is the LCP element | Hero lazy-loaded, oversized or a CSS background | next/image with priority and accurate sizes |
| High LCP, content appears after a spinner | Above-the-fold data fetched in useEffect | Fetch in a Server Component; stream slower sections with Suspense |
| High LCP, text is the LCP element | Fonts from a third-party host or too many weights | next/font with only the subsets and weights you use |
| Poor INP across the site | Large client bundle; 'use client' high in the tree | Move 'use client' to leaf components; bundle analysis; next/dynamic |
| Poor INP on one interaction | Long task in an event handler | useTransition, useDeferredValue, server-side work, virtualised lists |
| Poor INP in field, fine in lab | Third-party scripts and tags | next/script with lazyOnload; prune the tag manager |
| High CLS on load | Images without dimensions; font swap | next/image sizing or fill with aspect ratio; next/font fallback metrics |
| High CLS after load | Ads, embeds or streamed content inserted without space | Sized placeholders; same-size Suspense fallbacks |
A practical order of work
- Find the failing URL groups and device type in Search Console.
- Reproduce each issue in DevTools.
- Fix templates, not individual pages; one layout change can fix thousands of URLs.
- Add a Lighthouse check to CI for regressions, and treat field data as the final verdict.
That template-first approach is how we build data-heavy portals. Schools18, a search portal covering 19,000+ schools on Strapi and Next.js, was built for programmatic SEO and fast performance with large data.
How VOCSO can help
VOCSO has been building software since 2009, with 600+ projects delivered, ISO 27001 certification and a 5.0★ rating on Clutch. Our Next.js development services cover Core Web Vitals audits, App Router migrations and performance fixes to existing sites, and our frontend development team handles the image, font and JavaScript work that moves these metrics. If you need extra hands on your own team, you can also hire Next.js developers from us, under NDA from day one. Contact us with your Search Console report and we will tell you where to start.
FAQ
What are the “good” Core Web Vitals thresholds?
LCP of 2.5 seconds or less, INP of 200 milliseconds or less and CLS of 0.1 or less, each at the 75th percentile of visits, assessed separately for mobile and desktop.
My Lighthouse score is high, so why does Search Console say my pages fail?
Lighthouse is a single lab run on a simulated device. Search Console uses field data from real Chrome users on their own devices and networks, with real interactions. Field data is what counts.
How long does it take for fixes to show in Search Console?
CrUX uses a rolling 28-day window, so improvements appear gradually over about four weeks. Your own useReportWebVitals data shows the change sooner.
Does using the App Router automatically make my site fast?
No. Server Components make it easier to ship less JavaScript, but a misplaced 'use client', client-side data fetching or heavy third-party scripts can still fail every metric.
Should I use next/image for every image?
For content images, yes: you get responsive sizes, modern formats and reserved space. Add priority to the LCP image and configure remotePatterns for CMS-hosted images.
Is INP affected by the server?
Mostly not. INP is dominated by main-thread work on the device. A slow Server Action does not directly hurt INP if you show immediate feedback, such as a pending state, while it runs.
Related reading
- Next.js SEO: A Technical Checklist for the App Router
- Static Site Generation (SSG) vs Server-Side Rendering (SSR) in NextJS: Which is Best for SEO?
- NextJS vs ReactJS: When to Use Each and Why













Leave a Reply