Next.js Web Vitals: Measuring and Optimizing CLS, LCP, and INP
Measure and optimize Core Web Vitals in Next.js. Covers LCP optimization with priority images and preloading, CLS prevention with aspect ratios and font loading, INP improvement with useTransition and scheduler.yield, and sending vitals to analytics.
Core Web Vitals affect SEO rankings, user experience, and conversion rates. Google uses LCP, CLS, and INP as ranking signals, and real-user data from Chrome is factored into search results. A poor CLS score from a loading font or a slow LCP from an unoptimized hero image can cost you organic traffic β and users.
This guide covers measurement, root cause analysis, and concrete fixes for each vital in Next.js.
Understanding the Three Vitals
| Vital | What It Measures | Good | Needs Work | Poor |
|---|---|---|---|---|
| LCP β Largest Contentful Paint | How fast the main content loads | < 2.5s | 2.5β4s | > 4s |
| CLS β Cumulative Layout Shift | Visual stability (content jumping) | < 0.1 | 0.1β0.25 | > 0.25 |
| INP β Interaction to Next Paint | Responsiveness to user input | < 200ms | 200β500ms | > 500ms |
Measuring: reportWebVitals
// app/layout.tsx β send vitals to analytics
import { useReportWebVitals } from "next/web-vitals";
// This must be in a client component
// app/_components/web-vitals-reporter.tsx
"use client";
import { useReportWebVitals } from "next/web-vitals";
export function WebVitalsReporter() {
useReportWebVitals((metric) => {
// Send to your analytics pipeline
const body = {
name: metric.name, // "LCP" | "CLS" | "INP" | "FCP" | "TTFB"
value: Math.round(metric.value),
rating: metric.rating, // "good" | "needs-improvement" | "poor"
delta: Math.round(metric.delta),
id: metric.id,
navigationType: metric.navigationType,
};
// Option 1: PostHog
if (typeof window !== "undefined" && (window as any).posthog) {
(window as any).posthog.capture("web_vital", body);
}
// Option 2: Send to your own endpoint
if (navigator.sendBeacon) {
navigator.sendBeacon("/api/analytics/vitals", JSON.stringify(body));
}
// Option 3: Vercel Analytics (auto-collects if using @vercel/analytics)
// No code needed β Vercel collects automatically
});
return null;
}
// app/layout.tsx β include reporter
import { WebVitalsReporter } from "./_components/web-vitals-reporter";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<WebVitalsReporter />
{children}
</body>
</html>
);
}
π Looking for a Dev Team That Actually Delivers?
Most agencies sell you a project manager and assign juniors. Viprasol is different β senior engineers only, direct Slack access, and a 5.0β Upwork record across 100+ projects.
- React, Next.js, Node.js, TypeScript β production-grade stack
- Fixed-price contracts β no surprise invoices
- Full source code ownership from day one
- 90-day post-launch support included
LCP Optimization
LCP is almost always caused by a hero image, background image, or large text block that loads slowly.
Fix 1: Priority Hero Image
// β
Mark the above-the-fold image as priority
// This adds <link rel="preload"> to the document head and disables lazy loading
import Image from "next/image";
export function HeroSection() {
return (
<div className="relative h-[500px]">
<Image
src="/images/hero.jpg"
alt="Platform overview"
fill
priority // β Most important attribute for LCP
sizes="100vw"
className="object-cover"
quality={85}
/>
</div>
);
}
// β Without priority: image lazy-loads, causing high LCP
<Image src="/images/hero.jpg" alt="..." fill />
Fix 2: Preload Critical Resources
// app/layout.tsx β preload LCP image for all pages that use it
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
{/* Preload the hero image for the home page */}
<link
rel="preload"
as="image"
href="/images/hero.jpg"
// For responsive images, include imagesrcset
imageSrcSet="/images/hero-640.jpg 640w, /images/hero-1200.jpg 1200w"
imageSizes="100vw"
/>
</head>
<body>{children}</body>
</html>
);
}
Fix 3: Eliminate Render-Blocking Resources
// next.config.ts β inline critical CSS, defer non-critical scripts
export default {
experimental: {
optimizeCss: true, // Inline critical CSS
},
};
// For third-party scripts: use next/script with strategy
import Script from "next/script";
// β In <head>: blocks rendering
<script src="https://example.com/analytics.js" />
// β
afterInteractive: loads after page is interactive (doesn't block LCP)
<Script src="https://example.com/analytics.js" strategy="afterInteractive" />
// β
lazyOnload: loads during idle time (for least-critical scripts)
<Script src="https://example.com/chat.js" strategy="lazyOnload" />
CLS Optimization
CLS is caused by content that shifts after initial render β fonts swapping, images without dimensions, and late-loading banners.
Fix 1: Font Display Swap
// app/layout.tsx β Next.js font optimization prevents FOUT/CLS
import { Inter, DM_Sans } from "next/font/google";
// next/font automatically:
// 1. Downloads the font file at build time
// 2. Self-hosts it (no Google Fonts request at runtime)
// 3. Inlines the @font-face with font-display: optional (no layout shift)
// 4. Provides the CSS variable for use in Tailwind
const inter = Inter({
subsets: ["latin"],
display: "swap", // Use "optional" to completely eliminate CLS from fonts
variable: "--font-inter",
preload: true,
});
// "optional" = uses fallback font if system font loads faster
// No layout shift, but some users may see fallback font on slow connections
// "swap" = swap to loaded font (slight CLS acceptable)
Fix 2: Image Dimensions
// β No dimensions: browser doesn't know how much space to reserve β CLS
<img src="/avatar.jpg" alt="User" />
// β
Explicit dimensions: browser reserves space before image loads
<Image src="/avatar.jpg" alt="User" width={64} height={64} />
// β
Fill with aspect-ratio container: no dimensions needed
<div className="relative aspect-video w-full">
<Image src="/thumbnail.jpg" alt="..." fill className="object-cover" />
</div>
// β
For dynamic images where dimensions are unknown:
// Use CSS aspect-ratio with a placeholder background
<div className="aspect-[4/3] bg-gray-100 relative overflow-hidden rounded-lg">
<Image src={url} alt="..." fill className="object-cover" />
</div>
Fix 3: Reserve Space for Dynamic Content
// β Banner appears after auth check β content shifts down
function Layout({ children }: { children: React.ReactNode }) {
const { isPastDue } = useBillingStatus();
return (
<>
{isPastDue && <PaymentBanner />} {/* Appears late β CLS */}
{children}
</>
);
}
// β
Reserve space for banner with server-side render
// Or use a min-height placeholder while loading
function Layout({ children, paymentStatus }: { children: React.ReactNode; paymentStatus: string }) {
return (
<>
<div className={paymentStatus === "past_due" ? "" : "h-0 overflow-hidden"}>
<PaymentBanner />
</div>
{children}
</>
);
}
π Senior Engineers. No Junior Handoffs. Ever.
You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.
- MVPs in 4β8 weeks, full platforms in 3β5 months
- Lighthouse 90+ performance scores standard
- Works across US, UK, AU timezones
- Free 30-min architecture review, no commitment
INP Optimization
INP measures the time from user interaction to when the next frame is painted. Long JavaScript tasks on the main thread delay this.
Fix 1: useTransition for State Updates
// β Synchronous state update blocks main thread
function SearchPage() {
const [query, setQuery] = useState("");
const [results, setResults] = useState<Item[]>([]);
function handleSearch(value: string) {
setQuery(value);
// Filtering 10,000 items synchronously β blocks input response
setResults(allItems.filter((item) =>
item.name.toLowerCase().includes(value.toLowerCase())
));
}
return (
<>
<input onChange={(e) => handleSearch(e.target.value)} />
{results.map((r) => <ResultRow key={r.id} item={r} />)}
</>
);
}
// β
Mark results update as non-urgent with useTransition
import { useTransition, useState } from "react";
function SearchPage() {
const [query, setQuery] = useState("");
const [results, setResults] = useState<Item[]>([]);
const [isPending, startTransition] = useTransition();
function handleSearch(value: string) {
setQuery(value); // Urgent: update input immediately
startTransition(() => {
// Non-urgent: defer filtering until after input re-renders
setResults(allItems.filter((item) =>
item.name.toLowerCase().includes(value.toLowerCase())
));
});
}
return (
<>
<input value={query} onChange={(e) => handleSearch(e.target.value)} />
<div className={isPending ? "opacity-60" : ""}>
{results.map((r) => <ResultRow key={r.id} item={r} />)}
</div>
</>
);
}
Fix 2: Break Up Long Tasks with scheduler.yield
// For CPU-intensive work that must run synchronously:
// Yield to the browser between chunks to allow interaction
async function processLargeDataset(items: Item[]): Promise<ProcessedItem[]> {
const results: ProcessedItem[] = [];
const CHUNK_SIZE = 100;
for (let i = 0; i < items.length; i += CHUNK_SIZE) {
const chunk = items.slice(i, i + CHUNK_SIZE);
for (const item of chunk) {
results.push(processItem(item));
}
// Yield to browser: allows pending interactions to be handled
if (i + CHUNK_SIZE < items.length) {
await scheduler.yield(); // Modern browsers (2025+)
// Fallback for older browsers:
// await new Promise(resolve => setTimeout(resolve, 0));
}
}
return results;
}
Fix 3: Debounce Expensive Handlers
// Debounce search to avoid running on every keystroke
import { useMemo } from "react";
import { debounce } from "lodash-es"; // Tree-shakeable
function SearchInput({ onSearch }: { onSearch: (query: string) => void }) {
const debouncedSearch = useMemo(
() => debounce(onSearch, 150), // 150ms debounce
[onSearch]
);
return (
<input
type="search"
onChange={(e) => debouncedSearch(e.target.value)}
placeholder="Searchβ¦"
/>
);
}
// For resize and scroll handlers: use passive event listeners
// next.config.ts or component:
// window.addEventListener("scroll", handler, { passive: true });
Lighthouse CI in GitHub Actions
# .github/workflows/lighthouse.yml
name: Lighthouse CI
on:
pull_request:
branches: [main]
jobs:
lighthouse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci && npm run build
- name: Run Lighthouse CI
run: |
npm install -g @lhci/cli
lhci autorun
env:
LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}
# .lighthouserc.json
# {
# "ci": {
# "collect": { "startServerCommand": "npm run start", "url": ["http://localhost:3000"] },
# "assert": {
# "preset": "lighthouse:recommended",
# "assertions": {
# "first-contentful-paint": ["warn", { "maxNumericValue": 2000 }],
# "largest-contentful-paint": ["error", { "maxNumericValue": 4000 }],
# "cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
# "interactive": ["warn", { "maxNumericValue": 3500 }]
# }
# }
# }
# }
Cost and Timeline Estimates
| Scope | Team | Timeline | Cost Range |
|---|---|---|---|
| Web Vitals audit + report | 1 dev | 1 day | $300β600 |
| Fix LCP (image priority + preload) | 1 dev | 0.5β1 day | $200β400 |
| Fix CLS (fonts + image dimensions) | 1 dev | 1β2 days | $400β800 |
| Fix INP (useTransition + chunking) | 1 dev | 2β3 days | $600β1,200 |
| Lighthouse CI integration | 1 dev | Half a day | $150β300 |
See Also
- Next.js Performance Optimization
- Next.js Image Optimization
- React Suspense Patterns
- Node.js Performance Profiling
- Next.js App Router Caching Strategies
Working With Viprasol
Web Vitals are measurable, fixable, and directly tied to organic search rankings. Our team audits your CWV scores in Chrome UX Report and PageSpeed Insights, identifies root causes (unoptimized hero images, font loading CLS, long tasks on input handlers), and ships fixes with before/after Lighthouse scores.
What we deliver:
reportWebVitalsintegration with PostHog or custom analytics endpoint- Hero image
priorityattribute and<link rel="preload">for LCP - next/font setup with
display: "optional"to eliminate font CLS useTransitionrefactor for search and filter interactions (INP)scheduler.yield()chunking for CPU-intensive data processing- Lighthouse CI GitHub Action with budget assertions
Talk to our team about your Web Vitals optimization β
Or explore our web development services.
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Need a Modern Web Application?
From landing pages to complex SaaS platforms β we build it all with Next.js and React.
Free consultation β’ No commitment β’ Response within 24 hours
Need a custom web application built?
We build React and Next.js web applications with Lighthouse β₯90 scores, mobile-first design, and full source code ownership. Senior engineers only β from architecture through deployment.