Core Web Vitals & E-E-A-T

How to Improve INP on a Next.js App — Practical Fixes

App Router gives you the tools, but defaults won't save you. Here's the INP optimization workflow for production Next.js sites.

Published May 24, 20268 min readBy RankCrab Team

Next.js gives you a better starting point than most frameworks for Core Web Vitals. Server Components ship zero client JavaScript by default. The App Router streams HTML progressively. next/image handles sizing and format. But none of that automatically solves INP — and a poorly structured Next.js app can have worse INP than a well-tuned WordPress site.

The INP problems in Next.js come from specific patterns. Here's how to identify them and fix each one.

The Three INP Failure Patterns in Next.js

1. Large Client Bundles

Every "use client" component you add ships JavaScript to the browser. When that JavaScript is large — complex component trees, heavy libraries, duplicate dependencies — it blocks the main thread during hydration and thereafter whenever it executes. Long hydration tasks are the most common Next.js INP failure mode.

2. Hydration Mismatches

When the server-rendered HTML doesn't match what React expects on the client, React re-renders the entire component tree. This creates a large synchronous task that blocks all input processing during the mismatch reconciliation. The browser console shows warnings; INP shows a spike on first interaction.

3. Slow Server Action Latency

Server Actions introduced in Next.js 14 move logic to the server, which should help INP. But if a Server Action takes 800ms to complete (slow DB query, cold function start), the INP for that interaction is at least 800ms — even if the client-side rendering is instant. The user's click triggers the action, the UI hangs waiting for the response, and INP captures the full round-trip.

Diagnosing Which Pattern Affects You

Open Chrome DevTools Performance panel, record a session with Web Vitals enabled, and interact with the page normally. Look for:

  • Large hydration tasks: A long task appearing immediately after page load, associated with react-dom/client in the flame chart. This is your bundle size and hydration cost.
  • Re-render spikes: Long tasks triggered by specific interactions, showing a full component tree re-render in the flame chart.
  • Network waterfall gaps: In the Network panel, interactions that trigger Server Actions show a pending network request before the paint. If that request is slow, INP is slow.

PageSpeed Insights Lab data shows INP but doesn't specify which interaction. For Next.js, use the useReportWebVitals hook to send INP data with interaction details to your analytics:

// app/layout.tsx
'use client';
import { useReportWebVitals } from 'next/web-vitals';

export function WebVitalsReporter() {
  useReportWebVitals((metric) => {
    if (metric.name === 'INP') {
      // Send to your analytics
      console.log('INP:', metric.value, 'Attribution:', metric.attribution);
    }
  });
  return null;
}

The attribution object in the Web Vitals library (version 3+) includes the interaction type and the element that was clicked, which tells you which component to fix.

Fix 1: Use loading.tsx for Streaming

If a page loads a large server component that takes time to render, the client receives the shell HTML quickly but the interactive parts arrive later. If the user tries to interact during this window, hydration hasn't completed yet and React queues the interaction — which INP captures as a long delay.

Add a loading.tsx file to stream the shell immediately and show a loading state while the heavy content arrives:

app/
  dashboard/
    loading.tsx    ← shown immediately while page.tsx resolves
    page.tsx       ← heavy server component
// app/dashboard/loading.tsx
export default function Loading() {
  return (
    <div className="dashboard-skeleton">
      <div className="skeleton-header" />
      <div className="skeleton-content" />
    </div>
  );
}

The skeleton renders instantly. React streams the real content when it's ready. Users can see and interact with the shell before the full page hydrates.

Fix 2: Be Surgical With "use client"

"use client" is the boundary where React adds hydration. Everything below that boundary in the component tree gets shipped to the browser as JavaScript. The wider that boundary, the larger the client bundle, and the more hydration work the browser does on load.

The fix: push "use client" as far down the tree as possible.

// Bad: entire section is client-side
'use client';

export function ProductSection({ product }) {
  return (
    <section>
      <ProductImage src={product.image} alt={product.name} />
      <ProductTitle title={product.title} />
      <ProductDescription text={product.description} />
      <AddToCartButton productId={product.id} />  {/* Only this needs client */}
    </section>
  );
}
// Good: only the interactive piece is a client component
// ProductSection.tsx (Server Component — no 'use client')
import { AddToCartButton } from './AddToCartButton';

export function ProductSection({ product }) {
  return (
    <section>
      <img src={product.image} alt={product.name} />
      <h2>{product.title}</h2>
      <p>{product.description}</p>
      <AddToCartButton productId={product.id} />
    </section>
  );
}

// AddToCartButton.tsx (Client Component)
'use client';
export function AddToCartButton({ productId }) {
  return <button onClick={() => addToCart(productId)}>Add to Cart</button>;
}

The static content renders on the server with zero client JS. Only the button hydrates.

Fix 3: Use next/dynamic for Client-Only Widgets

Heavy client-only components — date pickers, rich text editors, chart libraries, map embeds — should be loaded with next/dynamic so they don't block hydration of the rest of the page:

import dynamic from 'next/dynamic';

const RichTextEditor = dynamic(
  () => import('@/components/RichTextEditor'),
  {
    ssr: false,          // Don't render on server at all
    loading: () => <div className="editor-placeholder">Loading editor...</div>,
  }
);

export function PostEditor() {
  return (
    <div>
      <h1>Edit Post</h1>
      <RichTextEditor />  {/* Loads separately, doesn't block other hydration */}
    </div>
  );
}

ssr: false means the component isn't included in the server-rendered HTML at all — React only loads it on the client, after the rest of the page has hydrated. This is the right pattern for components that are purely interactive and don't need to be in the initial HTML for SEO.

Fix 4: Defer Analytics with next/script

Analytics and marketing scripts are a common INP culprit. By default they execute during page load and add event listeners that fire on every user interaction. Use next/script with strategy="lazyOnload" to push them to after the page is fully interactive:

// app/layout.tsx
import Script from 'next/script';

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        {children}
        <Script
          src="https://www.googletagmanager.com/gtag/js?id=GA_MEASUREMENT_ID"
          strategy="lazyOnload"  // Loads after everything else is interactive
        />
        <Script id="google-analytics" strategy="lazyOnload">
          {`
            window.dataLayer = window.dataLayer || [];
            function gtag(){dataLayer.push(arguments);}
            gtag('js', new Date());
            gtag('config', 'GA_MEASUREMENT_ID');
          `}
        </Script>
      </body>
    </html>
  );
}

strategy="lazyOnload" loads the script during browser idle time after the page is interactive. strategy="afterInteractive" (the default for third-party scripts) loads after hydration but before idle — still better than the default <script> tag, but lazyOnload is better for non-critical analytics.

Measuring Your Results

The Next.js useReportWebVitals hook sends metrics from real user sessions. Set it up to push INP values to your analytics so you can track the 75th percentile over time — that's the number Google uses for CWV scoring.

useReportWebVitals((metric) => {
  if (['INP', 'LCP', 'CLS'].includes(metric.name)) {
    fetch('/api/vitals', {
      method: 'POST',
      body: JSON.stringify({
        name: metric.name,
        value: metric.value,
        rating: metric.rating, // 'good' | 'needs-improvement' | 'poor'
        page: window.location.pathname,
      }),
    });
  }
});

Lab data from PageSpeed Insights won't fully capture INP for a Next.js app — the Lab simulates specific interactions that may not match your users' actual interaction patterns. Real field data is the ground truth.

For WordPress-specific INP fixes, see How to Fix High INP Score in WordPress. For the full Core Web Vitals overview including LCP and CLS thresholds, see the Core Web Vitals guide.

Every Core Web Vitals check, automated.

Lighthouse via Google PSI runs on every audit. Fix the slow page, watch the score climb.