Back to Blog

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.

Viprasol Tech Team
May 7, 2027
12 min read

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

VitalWhat It MeasuresGoodNeeds WorkPoor
LCP β€” Largest Contentful PaintHow fast the main content loads< 2.5s2.5–4s> 4s
CLS β€” Cumulative Layout ShiftVisual stability (content jumping)< 0.10.1–0.25> 0.25
INP β€” Interaction to Next PaintResponsiveness to user input< 200ms200–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

ScopeTeamTimelineCost Range
Web Vitals audit + report1 dev1 day$300–600
Fix LCP (image priority + preload)1 dev0.5–1 day$200–400
Fix CLS (fonts + image dimensions)1 dev1–2 days$400–800
Fix INP (useTransition + chunking)1 dev2–3 days$600–1,200
Lighthouse CI integration1 devHalf a day$150–300

See Also


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:

  • reportWebVitals integration with PostHog or custom analytics endpoint
  • Hero image priority attribute and <link rel="preload"> for LCP
  • next/font setup with display: "optional" to eliminate font CLS
  • useTransition refactor 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.

Share this article:

About the Author

V

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.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

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

Viprasol Β· Web Development

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.