Next.js Performance Optimization — Core Web Vitals Guide for 2026
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:
| Metric | What it measures | Good threshold | Poor 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 buildThis 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
priorityto every image — only the LCP element should have it; too many priorities defeat the purpose - Not setting
sizesonnext/image— withoutsizes, 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 ofimport debounce from 'lodash/debounce' - Not revalidating tags after Server Action mutations — pages serve stale cached HTML until the next full deploy
Best Practices
- Add
priorityto the hero image only — it triggers a<link rel="preload">in the HTML - Use
sizesattribute on everynext/imageto let the browser pick the right srcset size for the viewport - Set
export const revalidate = 3600at 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/imagewithpriorityon the hero image is the single most impactful LCP optimizationnext/fontself-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
fetchrequests in Next.js 15 are not cached by default; opt in with{ next: { revalidate: N } }for ISR- Bundle analysis with
@next/bundle-analyzerreveals large dependencies that should be replaced or lazy-loaded - CLS is prevented by always setting
widthandheighton images and reserving space for async content - Dynamic imports with
next/dynamicdefer loading non-critical components until they are needed
Advertisement