Foundgrove
← All posts

Web Design · 11 min read

Core Web Vitals on Next.js: How to Hit Perfect Scores

Summary

Next.js can hit 100/100 on Lighthouse, but not by accident. The specific knobs: next/image, next/font, RSC, third-party deferral, bundle audits.

By The Foundgrove team · Published June 16, 2026 · Updated June 29, 2026

Next.js is fast by default. Next.js is perfect by intention. The difference between a 92 and a 100 on Lighthouse Performance is six specific configuration decisions, and the difference matters because Core Web Vitals is a measurable Google ranking factor and a measurable conversion-rate factor.

This is an optimization checklist worth running on any Next.js project before launch. Hit all six and you land in the 95-100 range consistently. For broader Next.js context, see the marketing sites pillar.

What are the Core Web Vitals targets in 2026?

Google publishes three Core Web Vitals metrics with explicit thresholds. Below the 'good' threshold and you get a ranking signal boost; above the 'poor' threshold and you get a ranking penalty. In between is purgatory. The targets have not changed substantially since INP replaced FID in early 2024.

  • Largest Contentful Paint (LCP) — Good <2.5s, Poor >4.0s — the time the largest visible element renders
  • Interaction to Next Paint (INP) — Good <200ms, Poor >500ms — the responsiveness of the slowest user interaction
  • Cumulative Layout Shift (CLS) — Good <0.1, Poor >0.25 — the total layout shift during the page lifetime
  • Time to First Byte (TTFB) — supporting metric, target <800ms — the time from request to first response byte
  • First Contentful Paint (FCP) — supporting metric, target <1.8s — the time the first text or image renders

Google's CrUX (Chrome User Experience) dataset is the authoritative source — Lighthouse scores are a synthetic proxy. A page can score 100 on Lighthouse and still fail CWV if real users on slow devices are the bottleneck. Check both.

How do you fix LCP on Next.js?

LCP on marketing pages is almost always a hero image or a heading above the fold. The fix is concentrated in three places: use next/image with the priority prop on the LCP element, set the sizes prop correctly so the browser preloads the right resolution, and ensure the image is not behind a JavaScript-rendered wrapper.

  • next/image with priority — `<Image priority src='/hero.jpg' alt='...' sizes='100vw' fill />` for the hero
  • AVIF and WebP automatically — Next.js serves AVIF to Chrome, WebP to Safari, JPG fallback, ~60% smaller than original
  • sizes prop — tells the browser which resolution to fetch at each breakpoint; default of '100vw' is rarely right
  • Preload font for LCP heading — next/font already does this for fonts you load through it
  • Avoid client components in the LCP path — a 'use client' wrapper around the hero adds hydration latency
  • Self-host the LCP image — third-party image CDNs add DNS lookup + TLS handshake, +100-300ms

Switching the hero image from a `<img>` tag (or a third-party CDN URL) to next/image with priority and correct sizes typically produces a large LCP improvement — often from several seconds down to comfortably under the 2.5s 'good' threshold. It is the single highest-leverage change.

How do you fix CLS on Next.js?

CLS is almost always one of three things: web fonts loading and re-flowing headings, images without explicit dimensions, or third-party widgets (Calendly, chat, ads) injecting content above the fold. Each has a Next.js-specific fix.

  • next/font with display: swap — loads the font async, browser uses a fallback during load, swaps in without layout shift (next/font tunes the fallback to match metrics)
  • next/image automatically reserves space — the width/height props or fill+aspect-ratio prevent shift
  • Third-party widgets — render them with explicit min-height containers so the embed does not push content down
  • Avoid dynamic font loading from CSS @import — always use next/font (it self-hosts and pre-renders the @font-face)
  • Banner ads or cookie consent — render with a reserved-height container, never inject above the fold without space allocation

How do you fix INP on Next.js?

INP is the JavaScript story. The metric measures the slowest interaction during a page session — a button click, a form input, a menu open. Slow interactions almost always come from too much JavaScript blocking the main thread. Next.js gives you three structural tools: React Server Components (ship zero JS for content), code-splitting per route, and next/script for third-party deferral.

  • Default to Server Components — only mark 'use client' on components that need useState, useEffect, or browser APIs
  • Audit client component boundaries — a 'use client' at the layout level makes everything below it client; move the boundary down
  • Code-split heavy components — dynamic import with next/dynamic for components that load conditionally (modals, accordions, charts)
  • next/script lazyOnload — for analytics, chat widgets, and other non-critical scripts; loads after page idle
  • Defer Calendly, Intercom, Drift — these are the biggest INP offenders; load them on user interaction (click-to-load pattern)
  • Bundle audit — `@next/bundle-analyzer` shows you what is in your JS payload; aim for <80KB JS per route on marketing pages

On a typical Next.js marketing page, JS bundle should be 40-80KB transferred. If you are above 150KB, something heavy got imported into a server component path (Lodash, Moment, a charting library). The bundle analyzer makes this obvious.

How do you audit the JavaScript bundle?

Install @next/bundle-analyzer, run it once per release, and you catch 80% of bundle regressions before they hit production. The analyzer produces an HTML treemap showing every package in the client bundle, sized by transferred bytes. Anything over 30KB that should not be on the marketing page gets investigated.

  • Install — `npm i -D @next/bundle-analyzer`, wrap next.config.js export with `withBundleAnalyzer`
  • Run — `ANALYZE=true npm run build` opens the treemap in the browser
  • Common offenders — Moment.js (use date-fns or native Intl), Lodash (use individual imports or native methods), full icon libraries (use tree-shakable imports)
  • Verify tree-shaking — `import { Button } from 'ui-library'` should not pull in the whole library; check the analyzer output
  • Set a budget — fail CI if any route's first-load JS exceeds 100KB (configure in Lighthouse CI or a custom script)

How do you handle third-party scripts?

Third-party scripts — analytics (GA4), tag managers (GTM), chat widgets (Intercom, Drift), booking embeds (Calendly), conversion pixels (Meta, LinkedIn) — are the second-largest performance offender after unoptimized images. Each one adds DNS lookup, TLS, JavaScript parse/eval, and often dozens of further requests.

The Next.js fix is next/script with a strategy prop. afterInteractive (default) loads after the page is interactive — fine for GA4. lazyOnload waits until browser idle — use for chat widgets and non-critical pixels. worker (experimental) offloads to a web worker via Partytown. The click-to-load pattern (don't load Calendly until the user clicks 'Book a call') eliminates the worst offenders entirely on first paint.

  • GA4 — `<Script src='...' strategy='afterInteractive' />` — fine on most pages
  • GTM — strategy='afterInteractive', but audit what GTM is loading inside; GTM containers often hide 10+ extra scripts
  • Calendly embed — click-to-load: render a button that swaps in the Calendly iframe on click
  • Intercom / Drift — lazyOnload or click-to-load; chat widgets often add 200-400KB of JS
  • Meta Pixel, LinkedIn Insight Tag — afterInteractive; consider deferring until consent is granted
  • Partytown via @builder.io/partytown — experimental, offloads scripts to a worker; can move GA4 entirely off the main thread

How does partial prerendering help?

Partial Prerendering (PPR), stabilized in Next.js 15, lets a single page be partially static and partially dynamic. The static shell — header, hero, content above the fold — renders at build time and streams immediately. Dynamic holes (a logged-in banner, personalized pricing) render on the server and stream in after.

The performance impact is large: instead of the whole page being SSR (slow TTFB) because of one personalized element, you get SSG speed for 95% of the page and stream the 5% as it becomes available. LCP stays under 1.5s; the dynamic element pops in after without affecting the score.

How do you measure CWV in production?

Lighthouse is a synthetic proxy. Real-user data is the truth. Three sources, used together, give you a complete picture of CWV in production.

  • PageSpeed Insights — runs Lighthouse + pulls 28-day CrUX data for the URL; the dashboard view of CWV health
  • Google Search Console — Core Web Vitals report aggregates CrUX data across your whole site, segments by good/needs-improvement/poor
  • CrUX BigQuery dataset — historical CWV data per origin, monthly, free to query; useful for tracking long-term trends
  • Vercel Web Analytics or @vercel/speed-insights — measures real-user CWV from your own visitors, no aggregation lag
  • Next.js useReportWebVitals hook — fires CWV events to any analytics backend; useful for custom tracking

What does a perfect-score checklist look like?

Eight items. Run through them on every page that ranks or converts. Hit all eight and you land 95-100 on Lighthouse and pass CWV thresholds in CrUX consistently.

  • next/image on every image, priority on the LCP element, correct sizes prop
  • next/font for all web fonts, display: swap, no CSS @import
  • React Server Components by default; 'use client' only where genuinely needed
  • Third-party scripts via next/script with appropriate strategy; click-to-load for heavy widgets
  • Bundle size budget enforced in CI (<100KB first-load JS per marketing route)
  • No layout shift from cookie banners, ads, or widgets — reserve space
  • Static rendering for marketing pages; ISR for content that updates often; SSR only when truly needed
  • Monitor CrUX data monthly in Search Console; treat any 'needs improvement' threshold as a P1 ticket

For a CWV audit on your existing Next.js site, book a strategy call. For full-stack engagements that include performance budgets in CI, see our website design service.

Where does this fit in your stack?

If you're running a US service business, the playbook in this post pairs with our full services lineup and applies cleanly across our supported industries and US locations. If you want help implementing it, book a free strategy call — we'll review your current setup and prioritize the next three moves.

For the deeper engagement details, see our website design service. New to the terminology here? Our SEO & marketing glossary defines every acronym in this post.

What are the most common questions about this topic?

Common questions readers send us about this topic.

Can a Next.js site actually score 100 on Lighthouse?

Yes. On marketing pages with proper image handling, font loading, and minimal third-party JavaScript, a 100/100/100/100 Lighthouse result is achievable and repeatable. The harder part is keeping the score at 100 over time as marketing requests add analytics tags, chat widgets, and tracking pixels. Score regressions almost always come from new third-party scripts, not from Next.js itself.

What is the most common Core Web Vitals failure on Next.js?

LCP from an unoptimized hero image. Teams use a regular `<img>` tag or a third-party image CDN URL instead of next/image with the priority prop. The fix typically drops LCP from 3-4s to under 1.5s. The second-most-common is CLS from a cookie banner or ad slot above the fold without reserved space.

Does using a third-party CDN for images hurt LCP?

Usually yes. Third-party image CDNs add DNS lookup (20-100ms), TLS handshake (50-150ms), and a separate connection that the browser cannot preload as efficiently. next/image self-hosts images on your origin and serves AVIF/WebP automatically, which is faster than most third-party CDNs for the LCP image. For non-LCP images, third-party CDNs are fine.

How do I handle Calendly without destroying my INP?

Click-to-load. Render a button that says 'Book a call' as a static element. On click, dynamically inject the Calendly script and embed. This eliminates Calendly's 300-500KB of JavaScript from the initial page load entirely, while preserving the booking functionality for users who actually want to book. The same pattern works for Intercom, Drift, and YouTube embeds.

What is partial prerendering and should I enable it?

Partial Prerendering (PPR) lets a page be partially static and partially dynamic — static shell streams immediately at build-time speed, dynamic holes render on the server and stream in. It is stable in Next.js 15. Enable it for pages that are 90% static with a small dynamic element (a personalized banner, real-time inventory). For fully-static pages, it adds no benefit; for fully-dynamic pages, regular SSR is simpler.

How much JavaScript should a Next.js marketing page ship?

Aim for under 80KB transferred (gzipped) first-load JS per marketing route. Under 50KB is achievable with mostly Server Components. Above 150KB suggests a heavy dependency leaked into a client component path — run @next/bundle-analyzer to find it. Common offenders are Moment.js, Lodash, full icon libraries, or animation libraries imported into a shared layout.

Should I use Server Components for everything?

Default to yes, opt out for interactivity. Server Components ship zero JavaScript to the browser, which is the single biggest INP and bundle-size win. Mark a component 'use client' only when it needs useState, useEffect, useRef, or browser-only APIs. The common mistake is marking layouts or wrappers as client unnecessarily, which makes everything inside client.

Why does my Lighthouse score differ from my Search Console CWV report?

Lighthouse is a synthetic single-page audit on a simulated device. Search Console aggregates real-user data from CrUX over the last 28 days across all your traffic, devices, and network conditions. The real-user data is the truth — Google ranks based on CrUX, not Lighthouse. A 100 on Lighthouse can still 'need improvement' in CrUX if your real users are on slow Android devices or weak connections.

About Foundgrove

The Foundgrove team

Foundgrove helps US service businesses win qualified leads from search and AI. We write about the practical, measurable side of acquisition — what works in production, not what looks good in a conference deck.

Want help applying this to your business?

Book a free 30-minute call. We'll review your current acquisition stack and show you the three highest-leverage moves for your industry and state. Or read how our website design service works.

Free SEO & AI visibility auditGet my free audit