13.4 Performance Optimization — next/image, next/font, next/script, Partial Prerendering
Why Does Performance Matter?
Web performance directly impacts both user experience and business metrics. Based on Google's Core Web Vitals, three metrics — LCP (Largest Contentful Paint), INP (Interaction to Next Paint), and CLS (Cumulative Layout Shift) — are also factored into SEO rankings.
Next.js provides built-in components and features for performance optimization, allowing you to achieve optimized results without additional configuration.
Performance Metrics Summary
| Metric | Meaning | Good | Needs Improvement | Poor |
|---|---|---|---|---|
| LCP | Largest Contentful Paint (time to load main content) | ≤ 2.5s | ≤ 4s | > 4s |
| INP | Interaction to Next Paint (response time to interactions) | ≤ 200ms | ≤ 500ms | > 500ms |
| CLS | Cumulative Layout Shift | ≤ 0.1 | ≤ 0.25 | > 0.25 |
| FCP | First Contentful Paint | ≤ 1.8s | ≤ 3s | > 3s |
next/image — Image Optimization
Why Use <Image> Instead of <img>?
Using a regular HTML <img> tag loads the original image as-is. Next.js's <Image> component automatically handles the following:
- WebP/AVIF format conversion: Automatically selects the optimal format based on browser support
- Responsive resizing: Resizes images to match the device resolution
- Lazy loading: Defers loading of images outside the viewport
- Layout shift prevention: Eliminates CLS using
width/heightattributes - Priority control: Setting
priorityon hero images improves LCP
Basic Usage
import Image from 'next/image';
// Local image (size information automatically inferred)
import profilePic from '/public/profile.jpg';
export default function Avatar() {
return (
<Image
src={profilePic}
alt="Profile photo"
// width/height automatically inferred (local images)
className="rounded-full"
/>
);
}
// Remote image (width and height are required)
export function HeroImage() {
return (
<Image
src="https://images.unsplash.com/photo-example"
alt="Hero image"
width={1200}
height={630}
priority // Use for LCP images — preloads the image
className="w-full object-cover"
/>
);
}
Allowing Remote Image Domains
External URL images must have their domains registered in next.config.js for security.
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'images.unsplash.com',
port: '',
pathname: '/**',
},
{
protocol: 'https',
hostname: '**.amazonaws.com', // Wildcard support
},
],
},
};
module.exports = nextConfig;
fill Mode — Image That Fills the Parent Element
import Image from 'next/image';
export function CoverImage() {
return (
// Parent element needs position: relative and explicit size
<div className="relative h-64 w-full overflow-hidden rounded-lg">
<Image
src="/cover.jpg"
alt="Cover image"
fill
sizes="100vw"
className="object-cover" // Fills while maintaining aspect ratio with object-cover
/>
</div>
);
}
sizes Attribute — Responsive Optimization
The sizes attribute tells the browser how much space the image actually occupies. Specifying the correct sizes value prevents downloading unnecessarily large images.
<Image
src="/product.jpg"
alt="Product image"
width={800}
height={600}
sizes="
(max-width: 768px) 100vw,
(max-width: 1200px) 50vw,
33vw
"
/>
What this configuration means:
- Mobile (768px and below): Full screen width
- Tablet (1200px and below): Half the screen
- Desktop: One-third of the screen
placeholder — Blur Effect While Loading
import Image from 'next/image';
import productImg from '/public/product.jpg';
export function ProductCard() {
return (
<Image
src={productImg}
alt="Product"
width={400}
height={300}
placeholder="blur" // Blur effect while loading (auto-generated for local images)
// placeholder="blur"
// blurDataURL="data:image/png;base64,..." // Must be provided manually for remote images
/>
);
}
next/font — Font Optimization
The Problem with Font Loading
Using external font services (Google Fonts, etc.) directly causes:
- Additional DNS lookups and network requests
- FOUT (Flash of Unstyled Text): The default font appears briefly before the custom font loads
- CLS: Layout shift when the font is swapped
next/font downloads fonts at build time and serves them from your own server, solving all of these issues.
Using Google Fonts
// app/layout.tsx
import { Inter, Noto_Sans_KR, Fira_Code } from 'next/font/google';
// Latin font
const inter = Inter({
subsets: ['latin'],
display: 'swap', // font-display: swap to prevent FOUT
variable: '--font-inter',
});
// Korean font
const notoSansKr = Noto_Sans_KR({
subsets: ['latin'],
weight: ['400', '500', '700'], // Load only the needed weights
display: 'swap',
variable: '--font-noto-kr',
});
// Code font
const firaCode = Fira_Code({
subsets: ['latin'],
display: 'swap',
variable: '--font-fira-code',
});
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html
lang="en"
className={`${inter.variable} ${notoSansKr.variable} ${firaCode.variable}`}
>
<body className="font-sans">{children}</body>
</html>
);
}
Integrating with Tailwind CSS:
// tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./app/**/*.{js,ts,jsx,tsx}', './components/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
fontFamily: {
sans: ['var(--font-inter)', 'var(--font-noto-kr)', 'sans-serif'],
mono: ['var(--font-fira-code)', 'monospace'],
},
},
},
};
Using Local Fonts
import localFont from 'next/font/local';
const pretendard = localFont({
src: [
{
path: '../public/fonts/Pretendard-Regular.woff2',
weight: '400',
style: 'normal',
},
{
path: '../public/fonts/Pretendard-Bold.woff2',
weight: '700',
style: 'normal',
},
],
display: 'swap',
variable: '--font-pretendard',
});
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={pretendard.variable}>
<body className="font-sans">{children}</body>
</html>
);
}
next/script — Third-Party Script Optimization
Script Loading Strategies
Loading external scripts (Google Analytics, chatbots, ads, etc.) incorrectly can block page rendering and significantly degrade performance. next/script lets you control when scripts are loaded.
| Strategy | Description | Use Case |
|---|---|---|
beforeInteractive | Load before HTML parsing — blocks rendering | Polyfills, critical scripts |
afterInteractive | Load after page hydration (default) | Google Analytics, Tag Manager |
lazyOnload | Load during browser idle time | Chatbots, social widgets |
worker | Load in a Web Worker | Performance-intensive computations |
Basic Usage Examples
// app/layout.tsx
import Script from 'next/script';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
{/* Google Analytics */}
<Script
src="https://www.googletagmanager.com/gtag/js?id=GA_MEASUREMENT_ID"
strategy="afterInteractive"
/>
<Script id="google-analytics" strategy="afterInteractive">
{`
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'GA_MEASUREMENT_ID');
`}
</Script>
{/* Chat widget — load during idle time */}
<Script
src="https://cdn.example-chat.com/widget.js"
strategy="lazyOnload"
/>
</body>
</html>
);
}
Event Handlers
'use client';
import Script from 'next/script';
export default function MapPage() {
return (
<>
<div id="map" className="h-96 w-full" />
<Script
src="https://maps.googleapis.com/maps/api/js?key=API_KEY"
strategy="afterInteractive"
onLoad={() => {
// Initialize map after script loads
const map = new window.google.maps.Map(document.getElementById('map')!, {
center: { lat: 37.5665, lng: 126.978 },
zoom: 13,
});
}}
onError={(e) => {
console.error('Google Maps failed to load:', e);
}}
/>
</>
);
}
Partial Prerendering (PPR) — Hybrid Rendering
What is PPR?
Partial Prerendering is a revolutionary rendering approach introduced in Next.js 15. It optimizes both static and dynamic content simultaneously within a single page.
Limitations of traditional rendering approaches:
- SSG: The entire page is static → no personalization possible
- SSR: The entire page is dynamic → slow Time to First Byte (TTFB)
The PPR approach:
- Static Shell: Layout, navigation, hero images → sent immediately (cached)
- Dynamic Content: Cart, recommended products, user info → streamed via Suspense
Request → Instant static shell response → Dynamic content streaming completes
(Cache HIT) (rendering on server...)
Enabling PPR
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
ppr: 'incremental', // Incremental adoption (enable PPR for specific pages only)
},
};
module.exports = nextConfig;
PPR Usage Example
// app/shop/page.tsx
import { Suspense } from 'react';
import { unstable_noStore as noStore } from 'next/cache';
// Enable PPR for this page only
export const experimental_ppr = true;
// Static component — generated at build time, sent immediately
function StaticHero() {
return (
<section className="bg-gradient-to-r from-blue-600 to-purple-600 py-20 text-center text-white">
<h1 className="text-4xl font-bold">Welcome to Our Store</h1>
<p className="mt-4 text-xl">The best products at the best prices</p>
</section>
);
}
// Dynamic component — re-rendered on every request
async function PersonalizedRecommendations() {
noStore(); // Force dynamic rendering
const userId = await getCurrentUserId(); // Read user ID from cookies
const recommendations = await fetchRecommendations(userId);
return (
<div className="grid grid-cols-4 gap-4">
{recommendations.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}
async function CartSummary() {
noStore();
const cart = await getCartItems();
return <div>Cart: {cart.length} items</div>;
}
export default function ShopPage() {
return (
<main>
{/* Static — displayed immediately */}
<StaticHero />
{/* Dynamic — streamed via Suspense */}
<section className="container mx-auto py-12">
<h2 className="mb-6 text-2xl font-bold">Recommended Products</h2>
<Suspense
fallback={
<div className="grid grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="h-48 animate-pulse rounded-lg bg-gray-200" />
))}
</div>
}
>
<PersonalizedRecommendations />
</Suspense>
</section>
<Suspense fallback={<div>Loading cart...</div>}>
<CartSummary />
</Suspense>
</main>
);
}
Practical Example — Fully Optimized E-Commerce Product Page
Optimized Product Listing Page
// app/products/page.tsx
import Image from 'next/image';
import { Suspense } from 'react';
// Product type
interface Product {
id: number;
name: string;
price: number;
image: string;
rating: number;
}
// Product card component (with image optimization)
function ProductCard({ product, priority = false }: {
product: Product;
priority?: boolean;
}) {
return (
<div className="overflow-hidden rounded-xl bg-white shadow-md transition hover:-translate-y-1 hover:shadow-xl">
<div className="relative h-56">
<Image
src={product.image}
alt={product.name}
fill
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
className="object-cover"
priority={priority} // Priority load for the first 4 products
placeholder="blur"
blurDataURL="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="
/>
</div>
<div className="p-4">
<h3 className="font-semibold text-gray-900">{product.name}</h3>
<div className="mt-1 flex items-center justify-between">
<span className="text-lg font-bold text-blue-600">
${product.price.toLocaleString()}
</span>
<span className="text-sm text-yellow-500">
★ {product.rating}
</span>
</div>
<button className="mt-3 w-full rounded-lg bg-blue-600 py-2 text-sm font-medium text-white hover:bg-blue-700">
Add to Cart
</button>
</div>
</div>
);
}
// Skeleton loading UI
function ProductSkeleton() {
return (
<div className="overflow-hidden rounded-xl bg-white shadow-md">
<div className="h-56 animate-pulse bg-gray-200" />
<div className="p-4 space-y-2">
<div className="h-4 animate-pulse rounded bg-gray-200" />
<div className="h-4 w-2/3 animate-pulse rounded bg-gray-200" />
<div className="h-8 animate-pulse rounded bg-gray-200" />
</div>
</div>
);
}
// Data-fetching component
async function ProductList() {
const res = await fetch('https://api.example.com/products', {
next: { revalidate: 3600 }, // Revalidate every hour
});
const products: Product[] = await res.json();
return (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
{products.map((product, index) => (
<ProductCard
key={product.id}
product={product}
priority={index < 4} // Priority load for the first 4 images
/>
))}
</div>
);
}
export default function ProductsPage() {
return (
<main className="container mx-auto px-4 py-8">
<h1 className="mb-8 text-3xl font-bold text-gray-900">All Products</h1>
<Suspense
fallback={
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 8 }).map((_, i) => (
<ProductSkeleton key={i} />
))}
</div>
}
>
<ProductList />
</Suspense>
</main>
);
}
Caching Strategy Comparison
// Examples of various caching strategies
// 1. Static cache (fixed at build time)
const staticData = await fetch('https://api.example.com/config');
// 2. Time-based revalidation (ISR)
const revalidatedData = await fetch('https://api.example.com/products', {
next: { revalidate: 3600 }, // 1 hour
});
// 3. Tag-based revalidation (On-demand ISR)
const taggedData = await fetch('https://api.example.com/posts', {
next: { tags: ['posts'] }, // Manually refresh with revalidateTag('posts')
});
// 4. Dynamic (no cache)
const dynamicData = await fetch('https://api.example.com/user', {
cache: 'no-store',
});
On-demand revalidation Route Handler:
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(req: NextRequest) {
const { tag, secret } = await req.json();
// Validate the secret key
if (secret !== process.env.REVALIDATION_SECRET) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
revalidateTag(tag);
return NextResponse.json({ revalidated: true, tag });
}
Expert Tips
1. Analyzing Bundle Size with Bundle Analyzer
npm install @next/bundle-analyzer
// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
module.exports = withBundleAnalyzer({
// existing next.config
});
ANALYZE=true npm run build
After analysis, apply code splitting to large packages with dynamic imports:
import dynamic from 'next/dynamic';
// Load heavy chart library only when needed
const HeavyChart = dynamic(() => import('@/components/HeavyChart'), {
loading: () => <div className="h-64 animate-pulse bg-gray-200" />,
ssr: false, // Disable server rendering (for browser-only APIs)
});
2. Improving LCP with Image Preloading
Set the priority attribute on images at the very top of the viewport, like hero images.
// What you must NOT do
<Image src="/hero.jpg" alt="Hero" width={1200} height={630} />
// ↑ Missing priority → LCP score drops
// The correct approach
<Image src="/hero.jpg" alt="Hero" width={1200} height={630} priority />
3. Minimizing Client Bundle with React Server Components
Handle data fetching in Server Components, and only use Client Components for interactivity.
// app/dashboard/page.tsx — Server Component (default)
async function Dashboard() {
const data = await fetchData(); // Runs on server only, not included in bundle
return (
<div>
<StaticContent data={data} /> {/* Server Component */}
<InteractiveButton /> {/* Only Client Components are included in bundle */}
</div>
);
}
4. Minimize Load Size with Font Subset Selection
// Bad: Loading the entire font
const font = Inter({ subsets: ['latin', 'latin-ext', 'cyrillic'] });
// Good: Load only the subsets you actually use
const font = Inter({ subsets: ['latin'] }); // If only using Latin characters
const krFont = Noto_Sans_KR({ subsets: ['latin'] }); // Separate font for Korean
5. Experimental strategy="worker" for next/script
Offload heavy scripts to a Web Worker to prevent blocking the main thread.
// next.config.js
module.exports = {
experimental: {
nextScriptWorkers: true,
},
};
// Usage example
<Script src="/heavy-analytics.js" strategy="worker" />
6. Optimizing Dynamic OG Images with generateMetadata
// app/products/[id]/page.tsx
import { Metadata } from 'next';
export async function generateMetadata({ params }: { params: { id: string } }): Promise<Metadata> {
const product = await fetch(`https://api.example.com/products/${params.id}`).then(r => r.json());
return {
title: product.name,
description: product.description,
openGraph: {
images: [
{
url: product.image,
width: 1200,
height: 630,
alt: product.name,
},
],
},
};
}
7. Preventing Performance Regressions with Lighthouse CI
# .github/workflows/lighthouse.yml
name: Lighthouse CI
on: [push]
jobs:
lighthouse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci && npm run build
- name: Run Lighthouse CI
uses: treosh/lighthouse-ci-action@v10
with:
urls: |
http://localhost:3000/
http://localhost:3000/products
uploadArtifacts: true
temporaryPublicStorage: true