Next.js Performance Optimization
next/image, next/font, next/dynamic, Partial Prerendering, and bundle optimization
Core Patterns
- next/image for LCP and CLS
- next/font for Font CLS Elimination
- next/dynamic for Code Splitting
- Partial Prerendering (PPR)
- Bundle and Build Optimization
next/image for LCP and CLS
The <Image> component from next/image automates best practices: WebP/AVIF conversion, responsive srcset, lazy loading, and explicit dimensions to prevent CLS.
Basic Usage
import Image from 'next/image';
// ✅ CORRECT: next/image handles format, srcset, and lazy loading
export default function ProductCard({ product }: { product: Product }) {
return (
<Image
src={product.imageUrl}
alt={product.name}
width={400}
height={300}
className="product-image"
/>
);
}
Priority for LCP Image
// ✅ Set priority on the above-fold LCP image — disables lazy loading
// Only ONE image per page should have priority
export default function HeroSection() {
return (
<Image
src="/hero.jpg"
alt="Hero"
width={1200}
height={600}
priority // ← fetchpriority="high" + no lazy loading
/>
);
}
fill for Unknown Dimensions (Responsive)
// ✅ Use fill when parent container controls size
function Banner() {
return (
<div style={{ position: 'relative', width: '100%', height: 400 }}>
<Image
src="/banner.webp"
alt="Banner"
fill
sizes="100vw"
style={{ objectFit: 'cover' }}
/>
</div>
);
}
sizes for Responsive Images
// ✅ Provide sizes to help browser pick the correct srcset entry
function Thumbnail({ src, alt }: { src: string; alt: string }) {
return (
<Image
src={src}
alt={alt}
width={800}
height={600}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>
);
}
Without sizes, browser assumes 100vw and may download a 1200px image for a 300px slot.
Remote Images Require Domain Config
// next.config.js — add allowed image domains
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{ protocol: 'https', hostname: 'cdn.example.com', pathname: '/images/**' },
],
},
};
next/font for Font CLS Elimination
next/font self-hosts Google Fonts at build time, generates size-adjust CSS automatically, and eliminates font-related CLS and FOIT without configuration.
Google Fonts
// app/layout.tsx
import { Inter, Roboto_Mono } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
display: 'swap', // font-display: swap
variable: '--font-inter', // CSS variable for use in Tailwind/CSS
});
const robotoMono = Roboto_Mono({
subsets: ['latin'],
display: 'swap',
variable: '--font-roboto-mono',
});
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={`${inter.variable} ${robotoMono.variable}`}>
<body className={inter.className}>{children}</body>
</html>
);
}
/* Use CSS variables in global styles or Tailwind */
body { font-family: var(--font-inter), system-ui, sans-serif; }
code { font-family: var(--font-roboto-mono), monospace; }
Local Fonts
import localFont from 'next/font/local';
const brandFont = localFont({
src: [
{ path: './fonts/brand-regular.woff2', weight: '400', style: 'normal' },
{ path: './fonts/brand-bold.woff2', weight: '700', style: 'normal' },
],
variable: '--font-brand',
display: 'swap',
});
What next/font does automatically:
- Downloads and self-hosts Google Fonts at build time (no runtime CDN request)
- Generates
size-adjust,ascent-override,descent-overrideto minimize layout shift during font swap - Adds preload link in
<head>for the font - Applies
font-display: swap(oroptionalif specified)
next/dynamic for Code Splitting
next/dynamic is a Next.js wrapper around React.lazy with additional options for SSR control.
Basic Dynamic Import
import dynamic from 'next/dynamic';
// ✅ Component loaded only when rendered — code-split automatically
const HeavyChart = dynamic(() => import('./HeavyChart'), {
loading: () => <ChartSkeleton />, // shown while loading
});
export default function Dashboard() {
return <HeavyChart data={chartData} />;
}
Disable SSR for Browser-Only Components
// ✅ Components using window/document must skip SSR
const ClientOnlyMap = dynamic(() => import('./Map'), {
ssr: false,
loading: () => <MapPlaceholder />,
});
Conditional Loading (Feature Flags, Admin)
// ✅ Load admin panel only when user is admin
function AdminPage({ isAdmin }: { isAdmin: boolean }) {
const AdminPanel = dynamic(() => import('./AdminPanel'));
return isAdmin ? <AdminPanel /> : <Redirect to="/" />;
}
Named Export
// Component.tsx exports { ChartWidget }
const ChartWidget = dynamic(
() => import('./Component').then(mod => mod.ChartWidget),
);
Partial Prerendering (PPR)
PPR (Next.js 14 experimental, stable in 15) renders a static shell at build time with <Suspense>-wrapped dynamic sections streamed in. Pages feel instantly loaded while dynamic data streams.
Enable PPR
// next.config.js
const nextConfig = {
experimental: { ppr: true }, // Next.js 14
// or: ppr: 'incremental' for opt-in per route in Next.js 15
};
PPR Page Structure
import { Suspense } from 'react';
// ✅ Static shell renders immediately from CDN
// Dynamic sections stream in as data becomes available
export default function ProductPage({ params }: { params: { id: string } }) {
return (
<div>
{/* ✅ Static: rendered at build time, served from CDN */}
<StaticHeader />
<StaticNav />
{/* ✅ Dynamic: Suspense boundary creates PPR boundary */}
<Suspense fallback={<ProductSkeleton />}>
<DynamicProductDetails id={params.id} />
</Suspense>
{/* ✅ Static: more static content below */}
<StaticFooter />
</div>
);
}
// This component fetches fresh data on every request
async function DynamicProductDetails({ id }: { id: string }) {
const product = await fetch(`/api/products/${id}`, { cache: 'no-store' });
return <ProductCard product={await product.json()} />;
}
When to Use PPR
- Landing pages with dynamic sections (personalization, cart count, user avatar)
- Product pages with static layout but dynamic inventory/pricing
- Any page where most content is static but some data must be fresh
Bundle and Build Optimization
Analyze Bundle Size
# Install bundle analyzer
npm install --save-dev @next/bundle-analyzer
// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
module.exports = withBundleAnalyzer({
// ...your config
});
ANALYZE=true npm run build
# Opens browser with interactive bundle visualization
Compiler Options
// next.config.js
const nextConfig = {
compiler: {
removeConsole: process.env.NODE_ENV === 'production', // strip console.log
},
// SWC minifier is on by default in Next.js 13+
};
Optimize Third-Party Scripts
import Script from 'next/script';
// ✅ afterInteractive: loads after page is interactive (analytics)
<Script
src="https://www.googletagmanager.com/gtag/js"
strategy="afterInteractive"
/>
// ✅ lazyOnload: loads during browser idle time (chat widgets)
<Script src="https://widget.example.com/chat.js" strategy="lazyOnload" />
// ✅ beforeInteractive: critical scripts only (polyfills)
<Script src="/polyfills.js" strategy="beforeInteractive" />
// ❌ WRONG: Raw <script> bypasses Next.js optimizations
<script src="analytics.js" />
Common Pitfalls
Using priority on multiple images: Only one image per page should have priority. Multiple priority images compete for bandwidth and defeat the purpose.
Forgetting sizes with fill: When using fill, always provide sizes matching the CSS-constrained container size. Without it, browser downloads images larger than displayed.
next/font with CSS @import: Importing Google Fonts directly in CSS (@import url('https://fonts.googleapis.com/...')) bypasses next/font optimizations. Always use the next/font/google module.
PPR and cookies() / headers(): Server Components using cookies(), headers(), or searchParams inside a Suspense boundary automatically become dynamic, enabling PPR for that boundary. Outside Suspense, they opt the entire page out of PPR.
Related Topics
- data-fetching-patterns.md — fetch() cache options, unstable_cache, ISR
- routing-patterns.md — generateStaticParams for pre-generating dynamic pages
- react/references/performance.md — React-level re-render optimization
- web-performance — Framework-agnostic Core Web Vitals