Search across all documentation pages
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
// 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.
// ---- 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:
sizes: serves 400px image on mobile instead of 1200px desktop imagenext/font: self-hosted Inter, variable weight, zero external requests, zero CLSnext/image automatic optimization - Images are converted to WebP or AVIF on demand, resized to the exact dimensions needed, and served with appropriate Cache-Control headers. The optimization happens at request time (or build time for static imports) on the server.next/image generates a srcset attribute 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.loading="lazy". The browser only fetches them when they enter the viewport. The priority prop disables lazy loading and adds a <link rel="preload"> for LCP images.blurDataURL. The blur shows instantly while the full image loads, improving perceived performance.next/font self-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).Inter as 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 with next/font size adjustment, this produces near-zero CLS.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))}`}
/>
);
}priority - Typically one per page. Without it, your hero image lazy loads and LCP suffers by 500ms+.sizes - Without sizes, the browser downloads the 3840px variant for a 300px thumbnail. Match sizes to your CSS grid/layout breakpoints.alt - Decorative images get alt="" (empty string, not omitted). Meaningful images get descriptive alt text.fill when dimensions are unknown - User-uploaded avatars, CMS images with variable aspect ratios. Parent must have position: relative and explicit dimensions.width, height, and blurDataURL at build time. No manual values to maintain.remotePatterns, not domains - domains is deprecated. remotePatterns supports wildcards and path restrictions for security.formats: ["image/avif", "image/webp"] - AVIF is 50% smaller than JPEG. Next.js serves AVIF to supporting browsers and falls back to WebP.unoptimized on raster images - Only use it for SVGs. Raster images (JPEG, PNG) should always go through the optimization pipeline.quality={75-85} for photos - Default is 75. Bump to 80-85 for hero images where quality matters. Below 70, JPEG artifacts become visible.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 |
next/image provides full type checking for props including src, width, height, alt.import img from "./photo.jpg") are typed as StaticImageData with automatic width, height, and blurDataURL.next/font/google and next/font/local return objects with className, variable, and style properties.Missing priority on LCP image - The largest visible image (hero, product photo) defaults to lazy loading, delaying LCP. Fix: Add priority to the image that is the Largest Contentful Paint element. Usually one per page.
Wrong sizes attribute - Without sizes, the browser assumes the image is 100vw and downloads the largest srcset variant. A 400px card image downloads at 3840px. Fix: Set sizes to match the actual rendered width: sizes="(max-width: 768px) 100vw, 33vw".
External images without configuration - next/image rejects external URLs unless configured. Fix: Add domains to next.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 width and height, or use fill with a positioned parent container.
Font loading flash - Using @import or <link> for fonts from Google Fonts causes a flash of unstyled text and CLS. Fix: Use next/font/google exclusively. 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 fill without a positioned parent - The image renders with position: absolute and overflows its container, covering other content. Fix: Parent must have position: relative (or absolute/fixed) and explicit width/height or aspect ratio.
Using domains instead of remotePatterns - domains is deprecated and doesn't support path restrictions or wildcards. Fix: Switch to remotePatterns with protocol, hostname, and pathname for security.
Skipping sizes on grid/card images - A 25vw card image downloads the 3840px variant (10x larger than needed). Fix: Always set sizes to match your layout: "(max-width: 768px) 100vw, 25vw".
Using CSS background-image instead of next/image - Loses automatic optimization, lazy loading, srcset, and format conversion. Fix: Use fill + object-cover as shown in the hero banner variation above.
| 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 |
<link rel="preload"> tag in the HTML <head> for the image.srcset ensures the browser downloads only the size needed for the viewport.quality prop (default 75) can be tuned for further savings.font-display: swap combined with automatic size adjustment ensures near-zero layout shift.blurDataURL (e.g., via the plaiceholder library).sizes, the browser assumes the image is 100vw wide.srcset variant (up to 3840px) even for a 400px card image.sizes to match actual rendered width, e.g., sizes="(max-width: 768px) 100vw, 33vw".// next.config.ts
const nextConfig = {
images: {
remotePatterns: [
{ hostname: "cdn.example.com" },
{ hostname: "images.unsplash.com" },
],
},
};<link> tags for fonts block rendering and cause a flash of unstyled text.next/font/google exclusively -- it self-hosts the font at build time.import hero from "./hero.jpg" are typed as StaticImageData.src, width, height, and blurDataURL automatically.<Image>.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}>fill when the image should stretch to fill its parent container.position: relative and defined dimensions.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",
});Reviewed by Chris St. John·Last updated Jul 10, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥