How to improve LCP in Next.js apps
Next.js is built for performance, but default settings don't always give you the best Core Web Vitals out of the box. A few specific moves reliably drop LCP significantly.
1. Use next/image correctly
The <Image> component handles AVIF/WebP conversion, responsive sizing, and lazy loading. But it has a gotcha: always set priority on the LCP image.
import Image from 'next/image';
<Image
src="/hero.jpg"
alt="Hero"
width={1200}
height={630}
priority
fetchPriority="high"
/>
Without priority, the LCP image is lazy-loaded, which wrecks LCP. With it, Next.js preloads it and gives the browser a head start.
2. Prefer static (SSG) or ISR over SSR
// Per-route revalidation (ISR)
export const revalidate = 3600; // 1 hour
// Full static (SSG) — rarer, for truly static pages
export const dynamic = 'force-static';
ISR pages serve from cache on the first request after cache expiry, while a background regeneration happens. TTFB is fast, LCP inherits.
SSR pages (dynamic = 'force-dynamic') pay server-render cost on every request. Reserve for pages that genuinely need per-request data.
3. Split client-side JavaScript
The App Router's default is Server Components — zero JavaScript shipped for any component without 'use client'. Honor this default. Only mark components 'use client' if they genuinely need state, effects, or browser APIs.
For client components that are large but non-critical, dynamic-import them:
import dynamic from 'next/dynamic';
const HeavyChart = dynamic(() => import('./HeavyChart'), {
loading: () => <Skeleton />,
});
Shipped JavaScript is LCP's natural enemy — every byte has to be downloaded, parsed, and executed before hydration can complete.
4. Optimize fonts with next/font
import { Inter } from 'next/font/google';
const inter = Inter({ subsets: ['latin'], display: 'swap' });
Next.js self-hosts the font, avoiding a request to Google Fonts. It also auto-generates a size-adjust fallback so there's zero layout shift when the font loads.
For system-font-stack purism, skip next/font entirely and use -apple-system, BlinkMacSystemFont, … in your CSS — zero font requests, instant text paint.
5. Ship to the edge for hot pages
export const runtime = 'edge';
Edge runtime has faster cold-start and TTFB than Node runtime. Caveats: can't use native Node APIs; bundle size limits. For cached-heavy pages, the edge runtime pays for itself.
6. Use <Suspense> for below-the-fold streaming
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar />
</Suspense>
The main content renders immediately (LCP fires), sidebars stream in later. Works only with Server Components.
7. Audit third-party scripts with next/script
import Script from 'next/script';
<Script src="https://analytics.example.com" strategy="afterInteractive" />
Strategies: beforeInteractive (rare), afterInteractive (default for most), lazyOnload (after everything else), worker (moves to a Web Worker via Partytown).
For analytics, chat widgets, ad scripts — lazyOnload or worker is almost always correct. beforeInteractive should never be used for anything that isn't critical.
Sanity check
Run next build and look at the bundle analyzer output:
ANALYZE=true next build
If your first-load JS is over 100KB for a content page, something is being shipped that doesn't need to be. Drill into the chunks and find out what.
By Paulo de Vries · Published