React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

next-imagenext-fontlcpclswebpaviflazy-loadingblur-placeholdervariable-fonts

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/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.
  • srcset generationnext/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.
  • Lazy loading — By default, images below the fold use 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.
  • 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/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).
  • Variable fonts — A single variable font file replaces multiple weight-specific files. 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.

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/webpack

Lazy-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

  1. Every LCP image gets priority — Typically one per page. Without it, your hero image lazy loads and LCP suffers by 500ms+.
  2. Every image gets sizes — Without sizes, the browser downloads the 3840px variant for a 300px thumbnail. Match sizes to your CSS grid/layout breakpoints.
  3. Every image gets alt — Decorative images get alt="" (empty string, not omitted). Meaningful images get descriptive alt text.
  4. Use fill when dimensions are unknown — User-uploaded avatars, CMS images with variable aspect ratios. Parent must have position: relative and explicit dimensions.
  5. Use static imports for local images — You get automatic width, height, and blurDataURL at build time. No manual values to maintain.
  6. Configure remotePatterns, not domainsdomains is deprecated. remotePatterns supports wildcards and path restrictions for security.
  7. Set formats: ["image/avif", "image/webp"] — AVIF is 50% smaller than JPEG. Next.js serves AVIF to supporting browsers and falls back to WebP.
  8. Never use unoptimized on raster images — Only use it for SVGs. Raster images (JPEG, PNG) should always go through the optimization pipeline.
  9. 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.
  10. 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:

FormatCompressionBrowser SupportBest For
JPEGGoodUniversalPhotos, complex images
WebP25-35% smaller than JPEG97%+ browsersDefault choice for most images
AVIF50% smaller than JPEG92%+ browsersMaximum compression when supported
PNGLosslessUniversalIcons, screenshots with text
SVGVectorUniversalLogos, icons, illustrations

TypeScript Notes

  • next/image provides full type checking for props including src, width, height, alt.
  • Static imports (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.

Gotchas

  • 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 configurationnext/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 remotePatternsdomains 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.

Alternatives

ApproachTrade-off
next/imageAutomatic optimization; requires Next.js
Cloudinary or ImgixCDN-based optimization; external dependency and cost
<img> with manual srcsetFull control; no automatic optimization
CSS background-imageCannot use next/image; loses lazy loading and srcset
next/fontZero CLS, self-hosted; Next.js only
FontsourceSelf-hosted npm packages; manual setup
Variable fonts via CDNSingle 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).
  • srcset ensures the browser downloads only the size needed for the viewport.
  • The quality prop (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: swap combined 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 the plaiceholder library).
  • 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 is 100vw wide.
  • It downloads the largest srcset variant (up to 3840px) even for a 400px card image.
  • Fix: Set sizes to 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/google exclusively -- 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 as StaticImageData.
  • The type includes src, width, height, and blurDataURL automatically.
  • 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 fill when the image should stretch to fill its parent container.
  • The parent must have position: relative and 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",
});