Next.js Performance Optimization — Core Web Vitals Guide for 2026

Sanjeev SharmaSanjeev Sharma
7 min read

Advertisement

Introduction

Why This Matters

Google uses Core Web Vitals as ranking signals. A site with poor LCP and high CLS will rank below a comparable site with good scores. More importantly, performance is a user experience issue: a 100ms improvement in load time correlates with a 1% increase in conversion rates. Next.js 15 ships with tools to hit excellent Core Web Vitals scores, but you have to use them correctly.

The three metrics that matter most in 2026:

MetricWhat it measuresGood thresholdPoor threshold
LCP (Largest Contentful Paint)When main content loads< 2.5s> 4.0s
INP (Interaction to Next Paint)Responsiveness to input< 200ms> 500ms
CLS (Cumulative Layout Shift)Visual stability< 0.1> 0.25

Note: INP replaced FID (First Input Delay) as an official Core Web Vital in March 2024.

Optimizing LCP: Images

The LCP element is usually the hero image or the largest text block above the fold. For images, use next/image with priority:

// app/page.tsx
import Image from 'next/image'
 
export default function HomePage() {
  return (
    <main>
      {/* priority tells Next.js to preload this image */}
      <Image
        src="/hero.jpg"
        alt="Hero image"
        width={1200}
        height={630}
        priority            // critical — skips lazy loading for LCP element
        sizes="(max-width: 640px) 100vw, (max-width: 1024px) 75vw, 50vw"
        className="w-full h-auto"
      />
    </main>
  )
}

For remote images, add the domain to next.config.ts:

// next.config.ts
const nextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'images.example.com',
        pathname: '/uploads/**',
      },
    ],
    formats: ['image/avif', 'image/webp'],
  },
}

next/image automatically serves AVIF and WebP formats, generates multiple sizes via srcset, and lazy-loads all images that do not have priority.

Optimizing Fonts

Unoptimized fonts trigger layout shift (CLS) and add a network request that blocks rendering. next/font solves both:

// app/layout.tsx
import { Inter, Playfair_Display } from 'next/font/google'
 
const inter = Inter({
  subsets: ['latin'],
  display: 'swap',
  variable: '--font-inter',
})
 
const playfair = Playfair_Display({
  subsets: ['latin'],
  display: 'swap',
  variable: '--font-playfair',
  weight: ['400', '700'],
})
 
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" className={`${inter.variable} ${playfair.variable}`}>
      <body className="font-sans">{children}</body>
    </html>
  )
}

next/font downloads font files at build time and self-hosts them with your application — no external Google Fonts request at runtime, no layout shift.

Code Splitting and Lazy Loading

Next.js automatically splits code by route. For large components that are not in the initial viewport, use dynamic imports:

import dynamic from 'next/dynamic'
 
// Lazy load a heavy chart library — only loads when needed
const RevenueChart = dynamic(() => import('@/components/revenue-chart'), {
  loading: () => <div className="h-64 bg-gray-100 rounded animate-pulse" />,
  ssr: false, // for browser-only libraries like Chart.js
})
 
// Lazy load a modal — loads only when opened
const ContactModal = dynamic(() => import('@/components/contact-modal'))
 
export default function DashboardPage() {
  return (
    <div>
      <RevenueChart />
      <ContactModal />
    </div>
  )
}

Streaming with Suspense

Streaming lets Next.js send HTML progressively — the shell renders immediately, and slow data-fetching components stream in as they resolve:

// app/dashboard/page.tsx
import { Suspense } from 'react'
 
async function RecentOrders() {
  // This slow query does not block the rest of the page
  const orders = await fetch('/api/orders?recent=true', { cache: 'no-store' })
    .then(r => r.json())
  return <OrderTable orders={orders} />
}
 
async function AccountBalance() {
  const balance = await fetch('/api/balance').then(r => r.json())
  return <BalanceCard balance={balance} />
}
 
export default function DashboardPage() {
  return (
    <div className="grid grid-cols-2 gap-6">
      {/* These load in parallel, each independently */}
      <Suspense fallback={<SkeletonCard />}>
        <AccountBalance />
      </Suspense>
 
      <Suspense fallback={<SkeletonTable />}>
        <RecentOrders />
      </Suspense>
    </div>
  )
}

Without Suspense boundaries, the slowest data fetch blocks the entire page. With them, each section renders as soon as its data arrives.

Caching Strategy

// Static — generated at build time, never revalidated
const posts = await fetch('/api/posts', { cache: 'force-cache' })
 
// ISR — cached, but revalidated every hour
const featured = await fetch('/api/featured', { next: { revalidate: 3600 } })
 
// Tag-based revalidation — revalidated on demand
const post = await fetch(`/api/posts/${slug}`, { next: { tags: [`post-${slug}`] } })
 
// Dynamic — never cached (default in Next.js 15)
const liveData = await fetch('/api/live')

Trigger on-demand revalidation after content updates:

// app/actions.ts
'use server'
 
import { revalidateTag, revalidatePath } from 'next/cache'
 
export async function updatePost(id: string, data: unknown) {
  await db.posts.update({ where: { id }, data })
  revalidateTag(`post-${id}`)         // revalidate specific post
  revalidatePath('/blog')             // revalidate the blog listing
}

Bundle Analysis

Find what is making your JavaScript bundle large:

npm install -D @next/bundle-analyzer
// next.config.ts
import bundleAnalyzer from '@next/bundle-analyzer'
 
const withBundleAnalyzer = bundleAnalyzer({
  enabled: process.env.ANALYZE === 'true',
})
 
export default withBundleAnalyzer({
  // your next config
})
ANALYZE=true npm run build

This opens a visual treemap in your browser. Common findings: entire icon libraries imported instead of individual icons, large polyfills, and duplicate utility libraries.

Reducing CLS

CLS is caused by elements that shift layout after the initial render. Common fixes:

// Always set width and height on images — prevents layout shift
<Image src="/avatar.jpg" alt="User" width={48} height={48} />
 
// Reserve space for async content
function UserCard() {
  return (
    // min-height reserves space before data loads
    <div className="min-h-[120px]">
      <Suspense fallback={<div className="h-[120px] bg-gray-100 rounded animate-pulse" />}>
        <UserDetails />
      </Suspense>
    </div>
  )
}

Measuring Performance in CI

Run Lighthouse programmatically in your CI pipeline to catch regressions:

npm install -D @lhci/cli
# .github/workflows/lighthouse.yml
- name: Run Lighthouse CI
  run: npx lhci autorun --collect.url=http://localhost:3000
  env:
    LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}

Common Mistakes

  • Adding priority to every image — only the LCP element should have it; too many priorities defeat the purpose
  • Not setting sizes on next/image — without sizes, the browser downloads the full-size image on mobile
  • Wrapping the entire page in a single <Suspense> — this negates streaming; wrap individual slow sections
  • Importing entire libraries for one function: import _ from 'lodash' instead of import debounce from 'lodash/debounce'
  • Not revalidating tags after Server Action mutations — pages serve stale cached HTML until the next full deploy

Best Practices

  • Add priority to the hero image only — it triggers a <link rel="preload"> in the HTML
  • Use sizes attribute on every next/image to let the browser pick the right srcset size for the viewport
  • Set export const revalidate = 3600 at the top of content-heavy page files to enable ISR
  • Wrap each slow data-fetching component in its own <Suspense> boundary for parallel streaming
  • Run Lighthouse in CI with a budget: fail the build if LCP exceeds 2.5s or CLS exceeds 0.1

Key Takeaways

  • LCP, INP, and CLS are the three Core Web Vitals Google uses as ranking signals in 2026 (INP replaced FID in 2024)
  • next/image with priority on the hero image is the single most impactful LCP optimization
  • next/font self-hosts Google Fonts at build time, eliminating the external request and layout shift from font loading
  • Suspense boundaries enable streaming — each wrapped section renders independently as its data resolves
  • fetch requests in Next.js 15 are not cached by default; opt in with { next: { revalidate: N } } for ISR
  • Bundle analysis with @next/bundle-analyzer reveals large dependencies that should be replaced or lazy-loaded
  • CLS is prevented by always setting width and height on images and reserving space for async content
  • Dynamic imports with next/dynamic defer loading non-critical components until they are needed

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading