Skip to main content
Advertisement

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

MetricMeaningGoodNeeds ImprovementPoor
LCPLargest Contentful Paint (time to load main content)≤ 2.5s≤ 4s> 4s
INPInteraction to Next Paint (response time to interactions)≤ 200ms≤ 500ms> 500ms
CLSCumulative Layout Shift≤ 0.1≤ 0.25> 0.25
FCPFirst 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/height attributes
  • Priority control: Setting priority on 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:

  1. Additional DNS lookups and network requests
  2. FOUT (Flash of Unstyled Text): The default font appears briefly before the custom font loads
  3. 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.

StrategyDescriptionUse Case
beforeInteractiveLoad before HTML parsing — blocks renderingPolyfills, critical scripts
afterInteractiveLoad after page hydration (default)Google Analytics, Tag Manager
lazyOnloadLoad during browser idle timeChatbots, social widgets
workerLoad in a Web WorkerPerformance-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
Advertisement