Image & Font Performance — Optimize images and fonts for fast LCP and zero layout shift
Recipe
// next/image — automatic optimization, lazy loading, srcset
import Image from "next/image";
// next/font — self-hosted, zero CLS, subset, variable weight
import { Inter } from "next/font/google";
const inter = Inter({
subsets: ["latin"],
display: "swap",
variable: "--font-inter",
});
export default function HeroSection() {
return (
<section className={inter.className}>
<h1 className="text-5xl font-bold">Welcome</h1>
{/* Priority: preloads for LCP, no lazy loading */}
<Image
src="/hero.jpg"
alt="Product showcase"
width={1200}
height={600}
priority
placeholder="blur"
blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRg..."
sizes="100vw"
className="rounded-xl"
/>
</section>
);
}When to reach for this: Always. Every image should use next/image and every font should use next/font. These are not optimizations to add later — they are the baseline for any production Next.js app.
Working Example
// ---- BEFORE: Unoptimized images and fonts — LCP 4.2s, CLS 0.35 ----
// layout.tsx
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<html>
<head>
{/* External font CDN — blocks rendering, causes CLS */}
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700&display=swap"
rel="stylesheet"
/>
</head>
<body style={{ fontFamily: "Inter, sans-serif" }}>{children}</body>
</html>
);
}
// page.tsx
export default function HomePage() {
return (
<div>
{/* Unoptimized: no dimensions, no lazy loading, no format conversion */}
<img src="/hero-original.png" alt="Hero" />
{/* 2.4MB PNG, 3000x1500px, loaded eagerly even if below fold */}
<h1>Our Products</h1>
<div className="grid grid-cols-3 gap-4">
{products.map((p) => (
<div key={p.id}>
{/* No width/height — causes layout shift */}
<img src={p.image} alt={p.name} />
<p>{p.name}</p>
</div>
))}
</div>
</div>
);
}
// ---- AFTER: Optimized — LCP 1.4s, CLS 0.02 ----
// layout.tsx
import { Inter } from "next/font/google";
const inter = Inter({
subsets: ["latin"],
display: "swap",
variable: "--font-inter",
// Self-hosted: no external requests, no CLS from font swap
// Automatically subsets to only characters used
// Variable font: one file covers all weights (saves ~100KB vs separate files)
});
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={inter.variable}>
<body className="font-sans">{children}</body>
</html>
);
}
// page.tsx
import Image from "next/image";
import heroImage from "@/public/hero.jpg"; // Static import for automatic blur
export default function HomePage() {
return (
<div>
{/* Optimized hero: priority preload, blur placeholder, responsive srcset */}
<Image
src={heroImage}
alt="Product showcase featuring our latest collection"
priority // Preloads for LCP — no lazy loading
placeholder="blur" // Shows blurred version instantly (inline base64)
sizes="100vw" // Full-width image
quality={85} // Slightly reduced quality — saves 30-40% size
className="w-full h-auto rounded-xl"
// Automatic: WebP/AVIF conversion, srcset, lazy loading (except priority)
// 2.4MB PNG -> 180KB WebP at appropriate size
/>
<h1 className="text-4xl font-bold mt-8">Our Products</h1>
<div className="grid grid-cols-3 gap-4 mt-6">
{products.map((p, index) => (
<div key={p.id}>
<Image
src={p.image}
alt={p.name}
width={400}
height={300}
// First row visible on load — add priority
priority={index < 3}
placeholder="blur"
blurDataURL={p.blurDataURL}
sizes="(max-width: 768px) 100vw, 33vw"
className="rounded-lg"
/>
<p className="mt-2 font-medium">{p.name}</p>
</div>
))}
</div>
</div>
);
}What this demonstrates:
- Hero image: 2.4MB PNG to 180KB WebP (93% smaller), preloaded for LCP
- Blur placeholder: users see a blurred preview instantly instead of blank space
- Responsive
sizes: serves 400px image on mobile instead of 1200px desktop image next/font: self-hosted Inter, variable weight, zero external requests, zero CLS- LCP: 4.2s to 1.4s (67% faster), CLS: 0.35 to 0.02 (94% reduction)
Deep Dive
How It Works
next/imageautomatic optimization — Images are converted to WebP or AVIF on demand, resized to the exact dimensions needed, and served with appropriateCache-Controlheaders. The optimization happens at request time (or build time for static imports) on the server.- srcset generation —
next/imagegenerates asrcsetattribute with multiple sizes (640, 750, 828, 1080, 1200, 1920, 2048, 3840px by default). The browser selects the smallest image that fits the viewport, reducing bytes transferred. - Lazy loading — By default, images below the fold use
loading="lazy". The browser only fetches them when they enter the viewport. Thepriorityprop disables lazy loading and adds a<link rel="preload">for LCP images. - Blur placeholder — For static imports, Next.js generates a tiny (8x8) base64 blur image at build time. For dynamic images, you provide a
blurDataURL. The blur shows instantly while the full image loads, improving perceived performance. next/fontself-hosting — Fonts are downloaded at build time and served from the same domain as your app. This eliminates the DNS lookup, TCP connection, and TLS handshake required for external font CDN requests (saves 100-300ms).- Variable fonts — A single variable font file replaces multiple weight-specific files.
Interas a variable font is ~100KB instead of 400KB+ for separate 400, 500, 600, and 700 weight files. font-display: swap— Text renders immediately with a fallback font, then swaps to the custom font when loaded. Combined withnext/fontsize adjustment, this produces near-zero CLS.
Variations
Local custom fonts:
import localFont from "next/font/local";
const customFont = localFont({
src: [
{ path: "./fonts/custom-regular.woff2", weight: "400", style: "normal" },
{ path: "./fonts/custom-bold.woff2", weight: "700", style: "normal" },
],
display: "swap",
variable: "--font-custom",
});Remote images with blur generation:
// Generate blur data URL at build or request time
import { getPlaiceholder } from "plaiceholder";
async function ProductCard({ imageUrl }: { imageUrl: string }) {
const { base64 } = await getPlaiceholder(imageUrl);
return (
<Image
src={imageUrl}
alt="Product"
width={400}
height={300}
placeholder="blur"
blurDataURL={base64}
/>
);
}Art direction with different images per breakpoint:
export function ResponsiveHero() {
return (
<picture>
<source media="(max-width: 768px)" srcSet="/hero-mobile.webp" />
<source media="(max-width: 1200px)" srcSet="/hero-tablet.webp" />
<Image
src="/hero-desktop.jpg"
alt="Hero"
width={1920}
height={800}
priority
sizes="100vw"
/>
</picture>
);
}Fill mode — image fills its parent container:
// Use when you don't know exact dimensions, or the image should
// stretch/cover/contain its parent. Parent MUST have position + dimensions.
export function AvatarCard({ src, name }: { src: string; name: string }) {
return (
<div className="relative h-64 w-64 overflow-hidden rounded-full">
<Image
src={src}
alt={name}
fill
sizes="256px"
className="object-cover" // cover, contain, or fill
/>
</div>
);
}Background-style hero with fill + object-cover:
// Replaces CSS background-image — gets all next/image optimizations
export function HeroBanner({ title }: { title: string }) {
return (
<section className="relative h-[60vh] w-full">
<Image
src="/hero-bg.jpg"
alt=""
fill
priority
sizes="100vw"
quality={80}
className="object-cover"
/>
{/* Content overlay */}
<div className="relative z-10 flex h-full items-center justify-center">
<h1 className="text-5xl font-bold text-white drop-shadow-lg">{title}</h1>
</div>
</section>
);
}Responsive grid with correct sizes:
// sizes tells the browser which srcset variant to download BEFORE layout.
// Without it, the browser defaults to 100vw and downloads the 3840px variant.
export function ProductGrid({ products }: { products: Product[] }) {
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{products.map((p, i) => (
<div key={p.id} className="relative aspect-square">
<Image
src={p.image}
alt={p.name}
fill
// Matches the grid: 1 col on mobile, 2 on sm, 4 on lg
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
priority={i < 4} // preload first row only
className="rounded-lg object-cover"
/>
</div>
))}
</div>
);
}Custom image loader (Cloudinary, Imgix, custom CDN):
import Image from "next/image";
// Custom loader: next/image calls this to generate the URL for each srcset entry
function cloudinaryLoader({ src, width, quality }: { src: string; width: number; quality?: number }) {
return `https://res.cloudinary.com/demo/image/upload/w_${width},q_${quality || 75}/${src}`;
}
export function CloudinaryImage({ publicId, alt }: { publicId: string; alt: string }) {
return (
<Image
loader={cloudinaryLoader}
src={publicId}
alt={alt}
width={800}
height={600}
sizes="(max-width: 768px) 100vw, 50vw"
/>
);
}
// Or set globally in next.config.ts:
// images: { loader: "custom", loaderFile: "./lib/image-loader.ts" }SVG and icon images (skip optimization):
// next/image optimizes raster images (JPEG, PNG, WebP).
// For SVGs, skip optimization — they're already vector and tiny.
export function Logo() {
return (
<Image
src="/logo.svg"
alt="Acme Inc"
width={120}
height={40}
unoptimized // SVGs don't need resizing or format conversion
/>
);
}
// For inline SVGs with color control, import as a React component instead:
// import Logo from "./logo.svg"; // requires @svgr/webpackLazy-loaded below-fold gallery with loading override:
// Default: images lazy load. But you can be explicit.
// Useful for documentation or when combining with Intersection Observer.
export function Gallery({ images }: { images: string[] }) {
return (
<div className="columns-2 gap-4 lg:columns-3">
{images.map((src, i) => (
<Image
key={src}
src={src}
alt={`Gallery image ${i + 1}`}
width={600}
height={400}
loading="lazy" // explicit — same as default, but clear intent
placeholder="blur"
blurDataURL="data:image/svg+xml;base64,..." // tiny SVG shimmer
sizes="(max-width: 1024px) 50vw, 33vw"
className="mb-4 rounded-lg"
/>
))}
</div>
);
}Static imports with automatic blur (no blurDataURL needed):
// When you import a local image file, Next.js provides width, height,
// and blurDataURL automatically at build time. No manual values needed.
import productShot from "@/public/images/product-shot.jpg";
export function ProductHero() {
return (
<Image
src={productShot} // StaticImageData — includes width, height, blur
alt="Product shot"
placeholder="blur" // blur works automatically for static imports
priority
sizes="100vw"
className="w-full"
// No width, height, or blurDataURL needed — all inferred from the import
/>
);
}remotePatterns configuration with protocol and pathname:
// next.config.ts — granular control over allowed external image sources
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
images: {
remotePatterns: [
{
protocol: "https",
hostname: "cdn.example.com",
pathname: "/images/**", // only allow /images/ path
},
{
protocol: "https",
hostname: "*.unsplash.com", // wildcard subdomain
},
{
protocol: "https",
hostname: "avatars.githubusercontent.com",
},
],
// Override default device sizes for srcset generation
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
// Override image sizes for the `sizes` prop (smaller variants)
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
// Preferred formats — Next.js tries AVIF first, then WebP
formats: ["image/avif", "image/webp"],
},
};
export default nextConfig;Shimmer / skeleton placeholder (custom SVG):
// Instead of a blurred image, show an animated shimmer effect
const shimmer = (w: number, h: number) => `
<svg width="${w}" height="${h}" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="g">
<stop stop-color="#f6f7f8" offset="0%" />
<stop stop-color="#edeef1" offset="50%" />
<stop stop-color="#f6f7f8" offset="100%" />
</linearGradient>
</defs>
<rect width="${w}" height="${h}" fill="url(#g)" />
</svg>`;
function toBase64(str: string) {
return typeof window === "undefined"
? Buffer.from(str).toString("base64")
: window.btoa(str);
}
export function ShimmerImage({ src, alt }: { src: string; alt: string }) {
return (
<Image
src={src}
alt={alt}
width={400}
height={300}
placeholder="blur"
blurDataURL={`data:image/svg+xml;base64,${toBase64(shimmer(400, 300))}`}
/>
);
}Next.js Image Rules
- Every LCP image gets
priority— Typically one per page. Without it, your hero image lazy loads and LCP suffers by 500ms+. - Every image gets
sizes— Withoutsizes, the browser downloads the 3840px variant for a 300px thumbnail. Matchsizesto your CSS grid/layout breakpoints. - Every image gets
alt— Decorative images getalt=""(empty string, not omitted). Meaningful images get descriptive alt text. - Use
fillwhen dimensions are unknown — User-uploaded avatars, CMS images with variable aspect ratios. Parent must haveposition: relativeand explicit dimensions. - Use static imports for local images — You get automatic
width,height, andblurDataURLat build time. No manual values to maintain. - Configure
remotePatterns, notdomains—domainsis deprecated.remotePatternssupports wildcards and path restrictions for security. - Set
formats: ["image/avif", "image/webp"]— AVIF is 50% smaller than JPEG. Next.js serves AVIF to supporting browsers and falls back to WebP. - Never use
unoptimizedon raster images — Only use it for SVGs. Raster images (JPEG, PNG) should always go through the optimization pipeline. - Use
quality={75-85}for photos — Default is 75. Bump to 80-85 for hero images where quality matters. Below 70, JPEG artifacts become visible. - Don't lazy load above-the-fold images — The first 1-4 images visible on page load should have
priority. Everything below the fold stays lazy (the default).
Image format comparison:
| Format | Compression | Browser Support | Best For |
|---|---|---|---|
| JPEG | Good | Universal | Photos, complex images |
| WebP | 25-35% smaller than JPEG | 97%+ browsers | Default choice for most images |
| AVIF | 50% smaller than JPEG | 92%+ browsers | Maximum compression when supported |
| PNG | Lossless | Universal | Icons, screenshots with text |
| SVG | Vector | Universal | Logos, icons, illustrations |
TypeScript Notes
next/imageprovides full type checking for props includingsrc,width,height,alt.- Static imports (
import img from "./photo.jpg") are typed asStaticImageDatawith automaticwidth,height, andblurDataURL. next/font/googleandnext/font/localreturn objects withclassName,variable, andstyleproperties.
Gotchas
-
Missing
priorityon LCP image — The largest visible image (hero, product photo) defaults to lazy loading, delaying LCP. Fix: Addpriorityto the image that is the Largest Contentful Paint element. Usually one per page. -
Wrong
sizesattribute — Withoutsizes, the browser assumes the image is 100vw and downloads the largest srcset variant. A 400px card image downloads at 3840px. Fix: Setsizesto match the actual rendered width:sizes="(max-width: 768px) 100vw, 33vw". -
External images without configuration —
next/imagerejects external URLs unless configured. Fix: Add domains tonext.config.ts:images: { remotePatterns: [{ hostname: "cdn.example.com" }] }. -
Missing width and height — Images without dimensions cause layout shift. The browser cannot reserve space until the image loads. Fix: Always provide
widthandheight, or usefillwith a positioned parent container. -
Font loading flash — Using
@importor<link>for fonts from Google Fonts causes a flash of unstyled text and CLS. Fix: Usenext/font/googleexclusively. Never add<link>tags for Google Fonts. -
Too many font weights — Loading 6+ font weights increases total font file size significantly. Fix: Use a variable font and limit to the weights actually used in your design system (typically 400, 500, 700).
-
Using
fillwithout a positioned parent — The image renders withposition: absoluteand overflows its container, covering other content. Fix: Parent must haveposition: relative(orabsolute/fixed) and explicit width/height or aspect ratio. -
Using
domainsinstead ofremotePatterns—domainsis deprecated and doesn't support path restrictions or wildcards. Fix: Switch toremotePatternswithprotocol,hostname, andpathnamefor security. -
Skipping
sizeson grid/card images — A 25vw card image downloads the 3840px variant (10x larger than needed). Fix: Always setsizesto match your layout:"(max-width: 768px) 100vw, 25vw". -
Using CSS
background-imageinstead ofnext/image— Loses automatic optimization, lazy loading, srcset, and format conversion. Fix: Usefill+object-coveras shown in the hero banner variation above.
Alternatives
| Approach | Trade-off |
|---|---|
next/image | Automatic optimization; requires Next.js |
| Cloudinary or Imgix | CDN-based optimization; external dependency and cost |
<img> with manual srcset | Full control; no automatic optimization |
CSS background-image | Cannot use next/image; loses lazy loading and srcset |
next/font | Zero CLS, self-hosted; Next.js only |
| Fontsource | Self-hosted npm packages; manual setup |
| Variable fonts via CDN | Single file; still has external request overhead |
FAQs
What does the priority prop on next/image actually do?
- Disables lazy loading so the image loads immediately.
- Adds a
<link rel="preload">tag in the HTML<head>for the image. - Should only be used on the LCP image (typically one per page).
How much size reduction does next/image provide compared to raw images?
- Automatic WebP/AVIF conversion can reduce a 2.4MB PNG to ~180KB (93% smaller).
srcsetensures the browser downloads only the size needed for the viewport.- The
qualityprop (default 75) can be tuned for further savings.
Why does next/font eliminate CLS from fonts?
- Fonts are downloaded at build time and served from the same domain (no external requests).
font-display: swapcombined with automatic size adjustment ensures near-zero layout shift.- No DNS lookup, TCP connection, or TLS handshake for external font CDNs.
What is the difference between a variable font and separate weight files?
- A variable font is a single file covering all weights (e.g., 100-900).
- Separate files require one download per weight (400, 500, 700, etc.).
- Variable fonts save ~100KB+ compared to loading 4+ separate weight files.
How do blur placeholders work for static vs dynamic images?
- Static imports: Next.js generates a tiny (8x8) base64 blur at build time automatically.
- Dynamic images: You must provide a
blurDataURL(e.g., via theplaiceholderlibrary). - The blur shows instantly while the full image loads, improving perceived performance.
Gotcha: What happens if you omit the sizes attribute on next/image?
- Without
sizes, the browser assumes the image is100vwwide. - It downloads the largest
srcsetvariant (up to 3840px) even for a 400px card image. - Fix: Set
sizesto match actual rendered width, e.g.,sizes="(max-width: 768px) 100vw, 33vw".
How do you configure next/image for external (remote) image URLs?
// next.config.ts
const nextConfig = {
images: {
remotePatterns: [
{ hostname: "cdn.example.com" },
{ hostname: "images.unsplash.com" },
],
},
};Gotcha: Why should you never use a Google Fonts link tag in a Next.js app?
- External
<link>tags for fonts block rendering and cause a flash of unstyled text. - The external request adds DNS + TCP + TLS latency (100-300ms).
- Fix: Use
next/font/googleexclusively -- it self-hosts the font at build time.
How is StaticImageData typed when you import an image in TypeScript?
- Static imports like
import hero from "./hero.jpg"are typed asStaticImageData. - The type includes
src,width,height, andblurDataURLautomatically. - You get compile-time errors if you pass invalid props to
<Image>.
What type does next/font/google return and how do you use it in TypeScript?
import { Inter } from "next/font/google";
// Returns { className: string; variable: string; style: { fontFamily: string } }
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
// Use className on elements or variable on <html> for CSS variable access
<html className={inter.variable}>When should you use the fill prop instead of width and height on next/image?
- Use
fillwhen the image should stretch to fill its parent container. - The parent must have
position: relativeand defined dimensions. - Useful for hero banners or responsive layouts where exact pixel dimensions vary.
How do you set up a local custom font with next/font/local?
import localFont from "next/font/local";
const customFont = localFont({
src: [
{ path: "./fonts/custom-regular.woff2", weight: "400" },
{ path: "./fonts/custom-bold.woff2", weight: "700" },
],
display: "swap",
variable: "--font-custom",
});Related
- Core Web Vitals — LCP and CLS targets that images and fonts directly impact
- Server Component Performance — Image components as Server Components
- Bundle Optimization — Font files as part of the overall bundle budget
- Performance Checklist — Image and font audit items