Next Performance & Optimization¶
Next.js ships fast by default. Then you add images , custom fonts , and third-party scripts and suddenly your Lighthouse score looks like a 2010 WordPress site
Performance in Next.js is about configuring what the framework already provides. Image optimization , font loading , ISR tuning , and CDN caching are built in - you just need to enable them correctly. One unoptimized image can undo every caching strategy you've implemented
image optimization¶
The next/image component replaces HTML <img> tags. It's not optional
import Image from 'next/image'
export default function Hero() {
return (
<Image
src="/hero.jpg"
alt="Hero image"
width={1200}
height={600}
priority // above-the-fold images get priority loading
placeholder="blur" // show blur-up placeholder while loading
blurDataURL="data:image/jpeg;base64,..." // tiny blurred version
/>
)
}
What next/image does:
- Automatic WebP/AVIF conversion - serves modern formats when the browser supports them
- Responsive sizing - generates multiple sizes , serves the right one per viewport
- Lazy loading - images load when they scroll into viewport (except
priorityones) - CLS prevention - reserves space to prevent layout shift
// Remote images need domain allowlist
// next.config.ts
const nextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'images.unsplash.com',
pathname: '/**'
},
{
protocol: 'https',
hostname: 'cdn.example.com',
pathname: '/**'
}
]
}
}
Without remotePatterns , remote images fail with a 400 error. Every image domain must be explicitly allowed
// Optimize images from remote sources
<Image
src="https://images.unsplash.com/photo-123"
alt="Unsplash image"
width={800}
height={600}
sizes="(max-width: 768px) 100vw, 50vw" // responsive sizes
/>
The sizes attribute tells Next.js which image size to generate. Wrong sizes means wrong image dimensions shipped to the wrong device
font optimization¶
Custom fonts block rendering. Next.js's next/font eliminates that
// src/app/layout.tsx
import { Inter, Roboto_Mono } from 'next/font/google'
const inter = Inter({
subsets: ['latin'],
display: 'swap', // show fallback font immediately , swap when custom loads
variable: '--font-inter' // CSS variable for use in styles
})
const robotoMono = Roboto_Mono({
subsets: ['latin'],
display: 'swap',
variable: '--font-mono'
})
export default function RootLayout({
children
}: {
children: React.ReactNode
}) {
return (
<html lang="en" className={`${inter.variable} ${robotoMono.variable}`}>
<body>{children}</body>
</html>
)
}
/* Use the CSS variables in your styles */
body {
font-family: var(--font-inter);
}
code {
font-family: var(--font-mono);
}
For self-hosted fonts:
import localFont from 'next/font/local'
const myFont = localFont({
src: './fonts/my-custom-font.woff2',
display: 'swap',
variable: '--font-custom'
})
next/font downloads font files at build time and serves them from your domain. No external requests to Google Fonts. No render-blocking font loading
bundle analysis¶
See exactly what's in your JavaScript bundle
npm install @next/bundle-analyzer
// next.config.ts
import type { NextConfig } from 'next'
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true'
})
const nextConfig: NextConfig = {
// your config
}
export default withBundleAnalyzer(nextConfig)
ANALYZE=true npm run build
# Opens a visual report of your bundle in the browser
What to look for:
- Large dependencies in client bundles - moment.js (500kb) , lodash (200kb) → use tree-shakeable alternatives
- Server-only code in client - if you see Prisma , database drivers , or server utils in the client bundle , you're leaking
- Duplicate libraries - multiple versions of the same library from different dependencies
- Heavy Client Components - components marked
'use client'that import large libraries
ISR tuning¶
ISR is powerful but configuration matters
// Revalidate too often - database hammering
fetch('https://api.example.com/posts', {
next: { revalidate: 1 } // revalidates every second - why even use ISR?
})
// Revalidate too rarely - stale content
fetch('https://api.example.com/posts', {
next: { revalidate: 86400 } // 24 hours - users see stale data all day
})
// Smart revalidation - match content update frequency
fetch('https://api.example.com/posts', {
next: {
revalidate: 300, // 5 minutes - good balance for blog content
tags: ['posts'] // allows on-demand revalidation
}
})
On-demand revalidation via webhooks is the best pattern:
// src/app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache'
export async function POST(request: Request) {
const secret = request.headers.get('x-revalidation-secret')
if (secret !== process.env.REVALIDATION_SECRET) {
return Response.json({ error: 'Invalid secret' }, { status: 401 })
}
const { tag } = await request.json()
revalidateTag(tag)
return Response.json({ revalidated: true })
}
Trigger revalidation from your CMS or database:
curl -X POST https://your-app.com/api/revalidate \
-H "x-revalidation-secret: your-secret" \
-H "Content-Type: application/json" \
-d '{"tag": "posts"}'
edge caching¶
Next.js caches aggressively. Understanding the cache layers helps you tune performance
Full Route Cache (server-side): * Caches rendered HTML for static and ISR pages * Invalidated by revalidatePath() or revalidateTag() * On Vercel , cached at the edge
Data Cache (fetch responses): * Caches fetch() responses * Controlled by cache and next.revalidate options * Invalidated by revalidateTag() or time-based expiry
Router Cache (client-side): * Caches page payloads in the browser * Prefetched pages stored for 30 seconds (static) or 5 minutes (full navigation) * Invalidated by router.refresh()
// Force cache miss for fresh data
fetch('https://api.example.com/data', {
cache: 'no-store' // bypasses all caching
})
// Cache with tag for later invalidation
fetch('https://api.example.com/data', {
next: {
tags: ['dashboard-data'],
revalidate: 900 // 15 minutes
}
})
CDN strategies¶
Next.js static assets should be served from a CDN
// next.config.ts
const nextConfig = {
// Prefix for CDN - upload .next/static to your CDN
assetPrefix: process.env.CDN_URL || undefined,
// Set Cache-Control headers for static assets
async headers() {
return [
{
source: '/:all*(svg|png|jpg|jpeg|gif|webp|woff|woff2)',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=31536000, immutable'
}
]
}
]
}
}
On Vercel , CDN caching is automatic. The static files in _next/static are served with immutable cache headers and distributed across Vercel's edge network
For self-hosted deployments:
# Nginx configuration for Next.js static assets
location /_next/static {
alias /var/www/myapp/.next/static;
expires 365d;
add_header Cache-Control "public, immutable";
}
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
performance checklist¶
- Images use
next/imagewith explicit width/height - no oversized images - Above-fold images have
priorityattribute - no lazy-loading critical content - Fonts use
next/fontwithdisplay: swap- no FOIT (flash of invisible text) - ISR pages have sensible
revalidateintervals based on content update frequency - Bundle analyzed - no server code in client bundles , no oversized dependencies
router.refresh()used after mutations instead of full page reload- Scripts use
next/scriptwith appropriatestrategy(beforeInteractive,afterInteractive,lazyOnLoad) - Static assets have
immutablecache headers
prerequisites¶
next_13_deploy.md - you should understand deployment options and environment configuration
next → (end of section)