Web Performance Optimization for Next.js
Comprehensive guide to optimizing Next.js application performance for Core Web Vitals and user experience.
Core Web Vitals
Google's metrics for measuring real-world user experience. Poor performance on these affects search rankings.
Largest Contentful Paint (LCP)
What it measures: How fast the largest visible element (text block, image, video) appears on the page.
Target: < 2.5 seconds
How to improve:
- •Optimize images with
next/image - •Use dynamic imports for large components
- •Implement code splitting by route
- •Cache critical resources
- •Reduce server response time (TTFB)
// BAD: Large unoptimized image
<img src="/hero.jpg" alt="Hero" />
// GOOD: Using next/image with optimization
import Image from 'next/image'
<Image
src="/hero.jpg"
alt="Hero"
width={1920}
height={1080}
priority={true}
sizes="100vw"
/>
Interaction to Next Paint (INP)
What it measures: How long it takes the page to respond to user interaction (click, tap, key press).
Target: < 200 milliseconds
How to improve:
- •Reduce JavaScript execution time
- •Break up long tasks with
setTimeoutor scheduler - •Optimize React render performance
- •Use Web Workers for heavy computation
- •Defer non-critical work
// BAD: Long blocking task
function handleClick() {
const result = expensiveComputation()
updateState(result)
}
// GOOD: Defer heavy work
import { startTransition } from 'react'
function handleClick() {
startTransition(() => {
const result = expensiveComputation()
updateState(result)
})
}
Cumulative Layout Shift (CLS)
What it measures: Unexpected layout changes that occur after the page has loaded.
Target: < 0.1
How to improve:
- •Always specify dimensions for images and videos
- •Use
font-display: swapfor fonts - •Insert ads/embeds in containers with reserved space
- •Avoid inserting content above existing content
- •Use transform animations instead of position changes
// BAD: No dimensions, causes layout shift
<Image src="/image.jpg" alt="Test" />
// GOOD: Always include width and height
<Image
src="/image.jpg"
alt="Test"
width={800}
height={600}
/>
// BAD: Position-based animation
.box { position: relative; top: 0; }
.box.animate { top: 10px; } // Shifts layout
// GOOD: Transform animation
.box { transform: translateY(0); }
.box.animate { transform: translateY(10px); } // No layout shift
Streaming SSR and React Suspense
Streaming SSR allows you to send HTML incrementally while React renders.
How Streaming Works
// app/page.tsx
import { Suspense } from 'react'
import { ProfileCard } from '@/components/ProfileCard'
import { Loading } from '@/components/Loading'
export default function Page() {
return (
<div>
<h1>Dashboard</h1>
{/* This chunk sends immediately */}
<Suspense fallback={<Loading />}>
{/* This chunk sends once ProfileCard is ready */}
<ProfileCard />
</Suspense>
</div>
)
}
// Async component
async function ProfileCard() {
const profile = await fetchProfile()
return <div>{profile.name}</div>
}
Progressive Enhancement
Users see content progressively as it becomes available:
export default function ProductPage({ params }) {
return (
<>
{/* Critical content - highest priority */}
<ProductHeader />
<div className="grid">
{/* Medium priority */}
<Suspense fallback={<ProductSkeleton />}>
<ProductDetails productId={params.id} />
</Suspense>
{/* Lower priority - appears last */}
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews productId={params.id} />
</Suspense>
{/* Least critical */}
<Suspense fallback={<RelatedSkeleton />}>
<RelatedProducts productId={params.id} />
</Suspense>
</div>
</>
)
}
Partial Prerendering (PPR)
Combines the benefits of static and dynamic rendering. Static shell renders at build time, dynamic holes fill at request time.
When to Use PPR
// app/posts/[id]/page.tsx
import { notFound } from 'next/navigation'
import { Suspense } from 'react'
export const experimental_ppr = true // Enable PPR for this page
export default function PostPage({ params }) {
return (
<article>
{/* This part is prerendered at build time */}
<header>
<h1>Blog Post</h1>
<nav>Navigation</nav>
</header>
{/* This part is dynamic, rendered at request time */}
<Suspense fallback={<PostSkeleton />}>
<PostContent postId={params.id} />
</Suspense>
{/* Comments may have user-specific data */}
<Suspense fallback={<CommentsSkeleton />}>
<Comments postId={params.id} />
</Suspense>
{/* Related posts can be prerendered */}
<aside>
<RelatedPosts currentId={params.id} />
</aside>
</article>
)
}
async function PostContent({ postId }) {
// This data is fetched on each request
const post = await getPost(postId)
return <div>{post.content}</div>
}
Image Optimization with next/image
The next/image component optimizes images automatically.
Basic Usage
import Image from 'next/image'
import heroImage from '@/public/hero.jpg'
// Using static import (recommended)
export default function Hero() {
return (
<Image
src={heroImage}
alt="Hero image"
// width and height are automatic from import
priority // Load before other images
/>
)
}
Responsive Images
<Image
src="/responsive-image.jpg"
alt="Responsive example"
width={1200}
height={630}
sizes="
(max-width: 640px) 100vw,
(max-width: 1024px) 90vw,
(max-width: 1280px) 80vw,
1200px
"
/>
Image Formats and Sizes
export default function Gallery() {
const images = [
{ id: 1, src: '/image-1.jpg', alt: 'Image 1' },
{ id: 2, src: '/image-2.jpg', alt: 'Image 2' },
]
return (
<div className="grid">
{images.map((image) => (
<Image
key={image.id}
src={image.src}
alt={image.alt}
width={400}
height={300}
quality={80} // Lower quality = smaller file
placeholder="blur" // Show blur while loading
blurDataURL="data:image/..." // Optional custom blur
/>
))}
</div>
)
}
Fill Container
import Image from 'next/image'
export default function FullScreenImage() {
return (
<div className="relative w-full h-screen">
<Image
src="/full-screen.jpg"
alt="Full screen"
fill
className="object-cover"
/>
</div>
)
}
Font Optimization with next/font
Load fonts without layout shift and with better performance.
Google Fonts
// app/layout.tsx
import { Inter, Playfair_Display } from 'next/font/google'
const inter = Inter({
subsets: ['latin'],
weight: ['400', '500', '700'],
})
const playfair = Playfair_Display({
subsets: ['latin'],
weight: ['600', '700'],
variable: '--font-playfair',
})
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en" className={`${inter.className} ${playfair.variable}`}>
<body>{children}</body>
</html>
)
}
CSS Variables for Font Switching
// globals.css
:root {
font-family: var(--font-inter);
}
.heading {
font-family: var(--font-playfair);
font-weight: 700;
}
.subheading {
font-family: var(--font-playfair);
font-weight: 600;
}
Local Fonts
import localFont from 'next/font/local'
const customFont = localFont({
src: [
{
path: '../fonts/custom-regular.woff2',
weight: '400',
},
{
path: '../fonts/custom-bold.woff2',
weight: '700',
},
],
})
Script Optimization
Control when and how third-party scripts load.
Script Strategies
import Script from 'next/script'
export default function Page() {
return (
<>
{/* Strategy: beforeInteractive - blocks page render */}
{/* Use for critical scripts only (auth, tracking that must run first) */}
<Script
src="https://critical-vendor.com/script.js"
strategy="beforeInteractive"
/>
{/* Strategy: afterInteractive - loads after page is interactive (default) */}
{/* Use for most third-party scripts (analytics, ads) */}
<Script
src="https://analytics.example.com/script.js"
strategy="afterInteractive"
/>
{/* Strategy: lazyOnload - loads on scroll/interaction */}
{/* Use for non-critical, heavy scripts (chat widgets, comment systems) */}
<Script
src="https://widget.example.com/chat.js"
strategy="lazyOnload"
/>
{/* Worker scripts - run in Web Worker, don't block main thread */}
<Script
src="https://heavy-computation.com/worker.js"
worker="heavy-worker"
/>
</>
)
}
Bundle Analysis
Understand what's in your JavaScript bundle and optimize it.
Setup @next/bundle-analyzer
npm install -D @next/bundle-analyzer
Configuration
// next.config.ts
import withBundleAnalyzer from '@next/bundle-analyzer'
const withAnalyzer = withBundleAnalyzer({
enabled: process.env.ANALYZE === 'true',
})
export default withAnalyzer({
// your config here
})
Run Analysis
ANALYZE=true npm run build
This generates an interactive treemap showing:
- •Module sizes
- •Dependencies
- •Duplication
- •Opportunities to reduce bundle
Code Splitting Strategies
Automatic Route-Based Splitting
Next.js automatically splits by route. Each page is its own bundle:
// These are automatically split into separate bundles // app/page.tsx // app/about/page.tsx // app/blog/page.tsx // app/products/page.tsx
Dynamic Imports
Load components only when needed:
import dynamic from 'next/dynamic'
const HeavyChart = dynamic(() => import('@/components/Chart'), {
loading: () => <div>Loading chart...</div>,
ssr: false, // Optional: disable SSR for client-only components
})
export default function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
{/* Chart only loads when visible */}
<HeavyChart />
</div>
)
}
React.lazy for Code Splitting
import { lazy, Suspense } from 'react'
const ExpensiveComponent = lazy(
() => import('@/components/ExpensiveComponent')
)
export default function Page() {
return (
<Suspense fallback={<div>Loading...</div>}>
<ExpensiveComponent />
</Suspense>
)
}
Split by Interaction
let pendingRequest: Promise<typeof import('./HeavyModule')> | null = null
export default function Page() {
const [showModule, setShowModule] = useState(false)
const handleClick = async () => {
// Preload on hover/focus
if (!pendingRequest) {
pendingRequest = import('./HeavyModule')
}
// Actually load on click
setShowModule(true)
}
return (
<button onMouseEnter={preload} onClick={handleClick}>
Open Heavy Module
</button>
)
}
function preload() {
if (!pendingRequest) {
pendingRequest = import('./HeavyModule')
}
}
Edge Runtime Optimization
Use Edge Runtime for faster responses and lower latency.
Edge vs Node Runtime
// app/api/edge-route/route.ts
import { NextRequest } from 'next/server'
// Use Edge Runtime for geographic distribution and lower latency
export const runtime = 'edge'
export async function GET(request: NextRequest) {
// Access user's location
const country = request.geo?.country
const city = request.geo?.city
// Fast responses: simple transformations, routing, caching
return new Response(`Hello from ${country}`, {
headers: {
'cache-control': 'public, max-age=3600',
},
})
}
// app/api/database-route/route.ts
// Keep Node.js runtime for database queries
export const runtime = 'nodejs'
export async function GET(request: NextRequest) {
// Node.js features: full database access, complex operations
const data = await db.query('SELECT * FROM users')
return Response.json(data)
}
Caching Strategies
Data Cache (Server-Side)
// app/blog/page.tsx
// Cached indefinitely
export const revalidate = false
// Or with tag-based revalidation
export async function generateStaticParams() {
const posts = await fetch('https://api.example.com/posts', {
next: { tags: ['posts'] }
})
return posts.map(post => ({ slug: post.slug }))
}
// Revalidate every 1 hour
export const revalidate = 3600
export default async function BlogPage() {
const posts = await fetch('https://api.example.com/posts', {
next: {
revalidate: 3600, // ISR
tags: ['posts'], // For on-demand revalidation
},
})
return <div>{/* render posts */}</div>
}
HTTP Cache (Browser/CDN)
// app/api/public-data/route.ts
export async function GET() {
const data = await getPublicData()
return Response.json(data, {
headers: {
// Cache for 1 year (immutable content)
'cache-control': 'public, max-age=31536000, immutable',
},
})
}
// app/api/user-data/route.ts
export async function GET(request: NextRequest) {
const user = await getUser(request)
return Response.json(user, {
headers: {
// Private, no CDN cache, browser caches for 1 minute
'cache-control': 'private, max-age=60, must-revalidate',
},
})
}
Performance Measurement
Web Vitals Monitoring
// app/layout.tsx
'use client'
import { useReportWebVitals } from 'next/web-vitals'
export function WebVitals() {
useReportWebVitals((metric) => {
console.log(metric)
// Send to analytics
if (metric.label === 'web-vital') {
fetch('/api/analytics', {
method: 'POST',
body: JSON.stringify(metric),
})
}
})
return null
}
NextSpeed Insights
// next.config.ts
import { withSpeedInsights } from '@vercel/speed-insights/next'
export default withSpeedInsights({
// your config
})
Performance Checklist
See references/optimization-checklist.md for a detailed checklist.
Bundle Analysis Guide
See references/bundle-analysis.md for detailed bundle analysis instructions.
References
- •Next.js Image Documentation: https://nextjs.org/docs/app/api-reference/components/image
- •Next.js Font Documentation: https://nextjs.org/docs/app/building-your-application/optimizing/fonts
- •Core Web Vitals: https://web.dev/vitals/
- •Lighthouse: https://developers.google.com/web/tools/lighthouse
- •Web Vitals Library: https://github.com/GoogleChromeLabs/web-vitals