LCP Next.js 14+

Fix LCP in Next.js

Largest Contentful Paint (LCP) is the most common Core Web Vital issue in Next.js applications. Despite Next.js providing built-in optimization features, many developers miss critical configuration steps that leave their LCP scores in the "needs improvement" or "poor" range.

This guide walks through the five most impactful fixes for LCP in Next.js, with real code examples and before/after performance comparisons. These fixes apply to both the App Router and Pages Router.

Expected results

Following all five steps typically produces these improvements:

Before

4.2s

LCP score (Poor) — unoptimized Next.js defaults with standard img tags and no preloading

After

1.4s

LCP score (Good) — optimized with priority images, SSG, self-hosted fonts, and RSC

Step-by-step fix

Use next/image with the priority prop

The next/image component automatically optimizes images (WebP conversion, responsive sizing, lazy loading). But for the LCP image, you must add the priority prop — this enables preloading and disables lazy loading for that specific image.

Common mistake: Forgetting the priority prop on the LCP image. Without it, next/image uses loading="lazy" by default, which delays loading and directly hurts LCP.
JSX — Before (Bad)
// ❌ Standard img tag — no optimization
export default function Hero() {
  return (
    <img
      src="/hero-image.jpg"
      alt="Product showcase"
      style={{ width: '100%', height: 'auto' }}
    />
  );
}
JSX — After (Good)
// ✅ next/image with priority for LCP image
import Image from 'next/image';

export default function Hero() {
  return (
    <Image
      src="/hero-image.jpg"
      alt="Product showcase"
      width={1200}
      height={630}
      priority           // Preloads the image
      sizes="100vw"      // Full viewport width
      quality={85}       // Balance quality vs size
    />
  );
}

Switch to Static Generation or ISR

Pages using getServerSideProps generate HTML on every request, adding server processing time to TTFB. Switch to getStaticProps (Pages Router) or default static rendering (App Router) with ISR for pages that don't need real-time data.

TypeScript — App Router ISR
// app/blog/[slug]/page.tsx
// Revalidate every 60 seconds — ISR
export const revalidate = 60;

export async function generateStaticParams() {
  const posts = await getPosts();
  return posts.map((post) => ({ slug: post.slug }));
}

export default async function BlogPost({
  params
}: {
  params: { slug: string }
}) {
  const post = await getPost(params.slug);

  return (
    <article>
      <h1>{post.title}</h1>
      <Image
        src={post.coverImage}
        alt={post.coverAlt}
        width={1200}
        height={630}
        priority
      />
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  );
}

Optimize font loading with next/font

Web fonts are a hidden LCP bottleneck. The browser blocks text rendering until fonts load (FOIT) or shows fallback fonts that shift layout (FOUT). The next/font module self-hosts fonts, eliminating the round-trip to Google Fonts and enabling automatic subsetting.

TypeScript — app/layout.tsx
import { Inter, JetBrains_Mono } from 'next/font/google';

const inter = Inter({
  subsets: ['latin'],
  display: 'swap',
  variable: '--font-body',
});

const jetbrains = JetBrains_Mono({
  subsets: ['latin'],
  display: 'swap',
  variable: '--font-mono',
});

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en" className={`${inter.variable} ${jetbrains.variable}`}>
      <body>{children}</body>
    </html>
  );
}

Reduce JavaScript bundle size

Large JavaScript bundles block the main thread and delay rendering. Use next/dynamic for below-the-fold components, analyze your bundle with @next/bundle-analyzer, and aggressively tree-shake unused code. A common win: replacing moment.js (300KB) with date-fns (12KB) or dayjs (2KB).

TypeScript — Dynamic imports
import dynamic from 'next/dynamic';

// Heavy components loaded only when needed
const HeavyChart = dynamic(() => import('@/components/Chart'), {
  loading: () => <div className="chart-skeleton" />,
  ssr: false,  // Skip SSR for client-only components
});

const CommentSection = dynamic(
  () => import('@/components/Comments'),
  { ssr: false }
);

export default function Dashboard() {
  return (
    <main>
      {/* LCP content loads immediately */}
      <h1>Dashboard</h1>
      <MetricsOverview />

      {/* Below-fold components lazy-loaded */}
      <HeavyChart />
      <CommentSection />
    </main>
  );
}

Use React Server Components

If you're on the App Router, take advantage of React Server Components (RSC). Server Components don't send any JavaScript to the browser — they render HTML on the server and stream it to the client. This dramatically reduces bundle size and eliminates hydration delay for non-interactive content.

TypeScript — Server Component (default in App Router)
// This is a Server Component by default — no 'use client'
// It sends ZERO JavaScript to the browser
import Image from 'next/image';

async function getProduct(id: string) {
  const res = await fetch(`https://api.example.com/products/${id}`, {
    next: { revalidate: 3600 }, // Cache for 1 hour
  });
  return res.json();
}

export default async function ProductPage({
  params,
}: {
  params: { id: string }
}) {
  const product = await getProduct(params.id);

  return (
    <article>
      <Image
        src={product.image}
        alt={product.name}
        width={800}
        height={600}
        priority
      />
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      {/* Only interactive parts need 'use client' */}
      <AddToCartButton productId={product.id} />
    </article>
  );
}

Quick checklist

  • LCP image uses next/image with priority prop
  • Page uses SSG or ISR (not SSR) where possible
  • Fonts self-hosted with next/font and display: swap
  • Below-fold components use next/dynamic
  • Non-interactive content uses Server Components (App Router)
  • Third-party scripts use next/script with lazy strategy
  • Bundle analyzed with @next/bundle-analyzer

Frequently asked questions

A well-optimized Next.js site using SSG or ISR should achieve LCP under 1.8 seconds. With SSR, expect 2.0-2.5 seconds. If you're seeing LCP above 3 seconds, there are likely optimization opportunities in image handling, font loading, or JavaScript bundle size.

Not automatically. You must add the priority prop to the LCP image. Without it, next/image uses lazy loading by default, which actually hurts LCP. The component provides optimization tools (WebP conversion, responsive sizing, blur placeholders), but the priority prop is required to activate preloading for above-the-fold images.

Generally yes. The App Router enables React Server Components (less client JS), supports streaming SSR, and has better built-in optimizations. That said, a well-optimized Pages Router app can also achieve excellent LCP scores. The App Router advantage is most pronounced on JavaScript-heavy pages.

Third-party scripts (analytics, chat widgets, A/B testing tools) can add 500ms-2s to your LCP by competing for bandwidth and blocking the main thread. Use next/script with strategy="lazyOnload" or strategy="afterInteractive" to defer them. For analytics that must fire early, use the Web Vitals reporting callback instead.

SSG almost always wins on LCP because pre-built HTML serves from CDN edge nodes with near-zero TTFB. SSR adds server processing time per request. Use ISR (Incremental Static Regeneration) to combine SSG's performance with SSR's freshness — pages revalidate in the background while serving cached HTML to users.

Google rates LCP as 'good' when it is under 2.5 seconds at the 75th percentile. For Nextjs applications specifically, aim for under 2.0 seconds. Measure with field data from Chrome User Experience Report (CrUX) through PageSpeed Insights, as lab tests may not reflect real-user experience with third-party scripts and varying network conditions.

Continue learning