🖼️ Chapter 20: Images & Fonts
Two of the biggest levers on how fast your site feels, and the Next.js 16 defaults that quietly changed underneath them.
📖 Plain English Explanation
Images are almost always the heaviest thing on a page. Fonts are almost always the thing that makes it look broken while it loads.
Both have well-understood fixes that almost nobody implements by hand, because doing it properly means:
For images — generating five sizes of every file, converting to AVIF and WebP with fallbacks, writing correct srcset and sizes, lazy-loading below-the-fold images but not the hero, and reserving space so the layout doesn't jump.
For fonts — self-hosting instead of hitting Google's CDN, subsetting to the characters you use, preloading the right files, and matching fallback metrics so text doesn't reflow when the real font arrives.
next/image and next/font do all of it. This chapter is about using them correctly — and about the handful of defaults Next.js 16 changed, which will bite you on upgrade.
🌅 next/image
// app/page.tsx
import Image from 'next/image'
export default function Page() {
return (
<Image
src="/hero.jpg"
alt="A wooden desk with a laptop"
width={1200}
height={630}
priority
/>
)
}
// app/page.js
import Image from 'next/image'
export default function Page() {
return (
<Image
src="/hero.jpg"
alt="A wooden desk with a laptop"
width={1200}
height={630}
priority
/>
)
}
What you get for free:
- Format conversion — AVIF or WebP served to browsers that support them
- Responsive sizes — several widths generated, correct
srcsetemitted - Lazy loading — off-screen images aren't fetched until needed
- Zero layout shift —
width/heightreserve the space - Caching — optimized images cached at the edge and on disk
width and height are not display size
They're the aspect ratio. Next.js uses them to reserve space; CSS controls the rendered size:
<Image
src="/photo.jpg"
alt="Photo"
width={1600}
height={900}
className="w-full h-auto" // ← this decides how big it looks
/>
Getting the ratio wrong distorts the image. Getting it right and then styling with CSS is the normal workflow.
Local vs remote images
Local — imported, so dimensions are inferred and a blur placeholder is automatic:
import Image from 'next/image'
import hero from '@/public/hero.jpg'
export default function Page() {
return <Image src={hero} alt="Hero" placeholder="blur" />
}
Remote — you supply dimensions, and the host must be allow-listed:
<Image
src="https://images.example.com/photo.jpg"
alt="Photo"
width={800}
height={600}
/>
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
images: {
remotePatterns: [
{ protocol: 'https', hostname: 'images.example.com' },
{ protocol: 'https', hostname: '**.mycdn.net', pathname: '/assets/**' },
],
},
}
export default nextConfig
Without the allow-list you get:
Error: Invalid src prop on `next/image`, hostname "images.example.com"
is not configured under images in your `next.config.js`
That's a security control, not an inconvenience. Without it, anyone could point your image optimizer at arbitrary URLs and use your infrastructure as a free image-resizing proxy.
⚠️ What changed in Next.js 16
Five image defaults changed. If you upgrade an existing app, these will affect you.
⚠️ Changed in Next.js 16 —
minimumCacheTTLDefault went from 60 seconds → 4 hours (14400s).
Source images without a
cache-controlheader were being re-optimized every minute, burning CPU and money. Most images don't change hourly, let alone every minute.ts// to restore the old behavior images: { minimumCacheTTL: 60 }
⚠️ Changed in Next.js 16 —
qualitiesDefault went from any quality →
[75]only.tsx<Image src="/x.jpg" quality={90} … /> // coerced to 75To allow more:
tsimages: { qualities: [50, 75, 100] }Values not in the array are coerced to the nearest allowed one. This closes a cache-poisoning vector — previously an attacker could request 100 distinct qualities and fill your cache.
⚠️ Changed in Next.js 16 —
imageSizes
16was removed from the default array.Almost nobody serves a 16px-wide image, and on a
devicePixelRatio: 2display you'd fetch the 32px variant anyway. Dropping it shrinks everysrcsetyou ship.ts// to restore images: { imageSizes: [16, 32, 48, 64, 96, 128, 256, 384] }
⚠️ Changed in Next.js 16 — local images with query strings
Now require explicit configuration, to prevent enumeration attacks:
tsx<Image src="/assets/photo?v=1" alt="Photo" width={100} height={100} />tsimages: { localPatterns: [{ pathname: '/assets/**', search: '?v=1' }], }
⚠️ Changed in Next.js 16 — local IPs and redirects
Optimizing images from local/private IPs is blocked by default (SSRF protection), and redirects are capped at 3.
tsimages: { dangerouslyAllowLocalIP: true, // only in a private network you control maximumRedirects: 5, }The
dangerouslyprefix is honest — enable it only after you understand the SSRF risk.
⚠️ Also deprecated in Next.js 16
tsx// ❌ next/legacy/image is deprecated import Image from 'next/legacy/image' // ✅ import Image from 'next/image'ts// ❌ images.domains is deprecated images: { domains: ['example.com'] } // ✅ remotePatterns constrains protocol, path, and port images: { remotePatterns: [{ protocol: 'https', hostname: 'example.com' }] }
🎛️ The props that matter
priority — for above-the-fold images only
<Image src="/hero.jpg" alt="Hero" width={1200} height={600} priority />
Disables lazy loading and preloads the image. Use it on the LCP element and nothing else. Marking ten images priority means none of them is prioritized, and you've delayed everything else on the page.
Rule of thumb: one priority image per page, the one visible without scrolling.
fill — when you don't know the dimensions
<div className="relative h-64 w-full">
<Image
src={product.imageUrl}
alt={product.name}
fill
className="object-cover"
sizes="(max-width: 768px) 100vw, 33vw"
/>
</div>
The parent must be positioned (relative, absolute, or fixed) and have a height. object-cover or object-contain controls the crop.
sizes — the one people skip
Tells the browser how wide the image will render, so it can pick the right srcset entry before CSS is applied.
<Image
src="/photo.jpg"
alt="Photo"
fill
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
/>
Read it as: full width on phones, half on tablets, a third on desktop.
Without sizes, a fill image defaults to 100vw and the browser downloads a full-viewport-width file for a thumbnail. On a 4K monitor, that's a 3840px image in a 300px slot.
sizes is required with fill, and worth setting on any responsive image.
placeholder — no flash of empty space
// Local import — blurDataURL generated automatically
import hero from '@/public/hero.jpg'
<Image src={hero} alt="Hero" placeholder="blur" />
// Remote — you provide it
<Image
src={product.imageUrl}
alt={product.name}
width={400}
height={300}
placeholder="blur"
blurDataURL={product.blurHash}
/>
// Or a flat colour
<Image
src={url}
alt=""
width={400}
height={300}
placeholder="blur"
blurDataURL="data:image/svg+xml;base64,PHN2ZyB4bWxucz0i…"
/>
Store a tiny base64 blur (generated with plaiceholder or sharp) alongside each image in your database. It's a few hundred bytes and makes loading feel dramatically better.
loading and unoptimized
<Image src={url} alt="" width={100} height={100} loading="eager" /> // rare
<Image src="/logo.svg" alt="Logo" width={120} height={40} unoptimized />
SVGs are already small and vector — optimization does nothing useful, so unoptimized is appropriate.
🧩 Practical patterns
A product grid
// app/shop/ProductGrid.tsx
import Image from 'next/image'
import Link from 'next/link'
export function ProductGrid({ products }: { products: Product[] }) {
return (
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4">
{products.map((product, i) => (
<Link key={product.id} href={`/product/${product.slug}`}>
<div className="relative aspect-square overflow-hidden rounded-lg">
<Image
src={product.imageUrl}
alt={product.name}
fill
sizes="(max-width: 768px) 50vw, (max-width: 1024px) 33vw, 25vw"
className="object-cover transition-transform hover:scale-105"
priority={i < 4} // only the first row
placeholder="blur"
blurDataURL={product.blurDataUrl}
/>
</div>
<h3 className="mt-2 text-sm">{product.name}</h3>
</Link>
))}
</div>
)
}
// app/shop/ProductGrid.js
import Image from 'next/image'
import Link from 'next/link'
export function ProductGrid({ products }) {
return (
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4">
{products.map((product, i) => (
<Link key={product.id} href={`/product/${product.slug}`}>
<div className="relative aspect-square overflow-hidden rounded-lg">
<Image
src={product.imageUrl}
alt={product.name}
fill
sizes="(max-width: 768px) 50vw, (max-width: 1024px) 33vw, 25vw"
className="object-cover transition-transform hover:scale-105"
priority={i < 4}
placeholder="blur"
blurDataURL={product.blurDataUrl}
/>
</div>
<h3 className="mt-2 text-sm">{product.name}</h3>
</Link>
))}
</div>
)
}
Handling a broken image URL
'use client'
import Image from 'next/image'
import { useState } from 'react'
export function Avatar({ src, name }: { src: string | null; name: string }) {
const [failed, setFailed] = useState(false)
if (!src || failed) {
return (
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-slate-200">
{name[0]?.toUpperCase()}
</div>
)
}
return (
<Image
src={src}
alt={name}
width={40}
height={40}
className="rounded-full"
onError={() => setFailed(true)}
/>
)
}
onError needs a Client Component — one of the few legitimate reasons to make an image component client-side.
✍️ next/font
Fonts cause two problems: a render-blocking request to a third-party domain, and a layout shift when the real font swaps in.
next/font fixes both by downloading the font at build time, self-hosting it, and computing fallback metrics so the swap doesn't move anything.
Google Fonts
// app/layout.tsx
import { Inter } from 'next/font/google'
const inter = Inter({
subsets: ['latin'],
display: 'swap',
})
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={inter.className}>
<body>{children}</body>
</html>
)
}
// app/layout.js
import { Inter } from 'next/font/google'
const inter = Inter({ subsets: ['latin'], display: 'swap' })
export default function RootLayout({ children }) {
return (
<html lang="en" className={inter.className}>
<body>{children}</body>
</html>
)
}
No network request to Google at runtime. The font file is downloaded during next build and served from your own domain. That's a privacy win as well as a performance one — no user data goes to Google.
Local fonts
// app/layout.tsx
import localFont from 'next/font/local'
const satoshi = localFont({
src: [
{ path: './fonts/Satoshi-Regular.woff2', weight: '400', style: 'normal' },
{ path: './fonts/Satoshi-Italic.woff2', weight: '400', style: 'italic' },
{ path: './fonts/Satoshi-Bold.woff2', weight: '700', style: 'normal' },
],
display: 'swap',
variable: '--font-satoshi',
})
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={satoshi.variable}>
<body className="font-sans">{children}</body>
</html>
)
}
Multiple fonts with CSS variables
// app/layout.tsx
import { Inter, JetBrains_Mono } from 'next/font/google'
const inter = Inter({
subsets: ['latin'],
variable: '--font-sans',
display: 'swap',
})
const mono = JetBrains_Mono({
subsets: ['latin'],
variable: '--font-mono',
display: 'swap',
})
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={`${inter.variable} ${mono.variable}`}>
<body>{children}</body>
</html>
)
}
/* app/globals.css — Tailwind v4 */
@import 'tailwindcss';
@theme {
--font-sans: var(--font-sans), system-ui, sans-serif;
--font-mono: var(--font-mono), ui-monospace, monospace;
}
Now font-sans and font-mono in Tailwind resolve to your self-hosted fonts.
Variable fonts — use them
A variable font is one file containing every weight from 100 to 900. Prefer it over loading four static weights:
// ✅ one file, all weights
const inter = Inter({ subsets: ['latin'] })
// ❌ four files
const inter = Inter({ subsets: ['latin'], weight: ['400', '500', '600', '700'] })
Most modern Google Fonts have a variable version, and next/font uses it automatically when you omit weight.
display options
| Value | Behavior |
|---|---|
swap | Show fallback immediately, swap when ready. Default choice. |
optional | Use the font only if it's ready almost instantly; otherwise skip it entirely this visit |
block | Invisible text for up to 3s waiting for the font |
fallback | A middle ground between swap and optional |
Use swap unless you have a specific reason. optional is worth considering when brand fidelity matters less than never shifting.
Subsetting
const inter = Inter({ subsets: ['latin'] }) // ~15KB
const inter = Inter({ subsets: ['latin', 'cyrillic', 'greek'] }) // much larger
Only include scripts you actually render. This is the biggest single lever on font weight.
Scoping a font to one section
// app/blog/layout.tsx
import { Merriweather } from 'next/font/google'
const serif = Merriweather({ subsets: ['latin'], weight: ['400', '700'] })
export default function BlogLayout({ children }: { children: React.ReactNode }) {
return <div className={serif.className}>{children}</div>
}
The font is only preloaded on routes that use it.
⚠️ Common Pitfalls
1. Missing sizes on a fill image
Defaults to 100vw, so a 300px thumbnail downloads a 3840px file on a 4K screen. Always set sizes with fill.
2. priority on everything
{products.map((p) => <Image priority … />)} // ❌ prioritizes nothing
One per page, on the LCP element.
3. A hostname not in remotePatterns
Runtime error. Add the host — and constrain the path while you're there.
4. Wrong aspect ratio
width/height set the ratio. Mismatching the source distorts the image.
5. quality={90} after upgrading to 16
Silently coerced to 75. Add the value to images.qualities if you need it.
6. Images re-optimizing constantly
If your source images lack cache-control, the minimumCacheTTL default applies. In Next.js 16 that's 4 hours instead of 60 seconds — usually an improvement, but worth knowing if you expected faster updates.
7. Calling next/font inside a component
// ❌ re-evaluated on every render
export function Card() {
const inter = Inter({ subsets: ['latin'] })
}
// ✅ module scope
const inter = Inter({ subsets: ['latin'] })
export function Card() { … }
8. Still using <link> to Google Fonts
// ❌ in the root layout
<link href="https://fonts.googleapis.com/css2?family=Inter" rel="stylesheet" />
Render-blocking, a third-party connection, and a privacy issue. Use next/font.
9. Loading every weight
Four static weights is four files. Use the variable version.
10. Empty or decorative alt
<Image src="/photo.jpg" alt="" /> // ❌ if it conveys meaning
<Image src="/photo.jpg" alt="image" /> // ❌ useless
<Image src="/photo.jpg" alt="Barista pouring latte art" /> // ✅
<Image src="/divider.svg" alt="" aria-hidden /> // ✅ genuinely decorative
alt is required. An empty string is correct only for purely decorative images.
🎯 When & Why to Use
next/image when:
✅ Any photographic or raster content
✅ You want automatic format conversion and responsive sizes
✅ Layout stability matters (it always does)
Plain <img> when:
✅ Inline SVG icons
✅ A data: URI
✅ You're doing your own optimization pipeline
✅ Static export with no image optimizer available
next/font when:
✅ Always. There is no good reason to use a <link> to Google Fonts.
priority when:
✅ Exactly one image per page — the LCP element
fill when:
✅ Dimensions are unknown, or the container drives the size
⚠️ Requires a positioned parent AND a sizes prop
🏋️ Mini Practice Problems
Problem 1: Find the problems
export function Gallery({ photos }) {
return (
<div className="grid grid-cols-4">
{photos.map((p) => (
<Image key={p.id} src={p.url} alt="photo" fill priority quality={95} />
))}
</div>
)
}
Five issues. List and fix them, including one that's specific to Next.js 16.
Problem 2: Compute the waste
A 300×300 thumbnail rendered with fill and no sizes, on a 3840px-wide display with devicePixelRatio: 2. Roughly how much larger is the downloaded image than necessary? What one prop fixes it?
Problem 3: Font setup
Set up a project with:
- Inter for body text
- JetBrains Mono for code
- A serif face used only on
/blog - All three self-hosted with no layout shift
- Tailwind utilities wired to the first two
Problem 4: Migrate
// next.config.js
module.exports = {
images: {
domains: ['cdn.example.com'],
minimumCacheTTL: 60,
},
}
import Image from 'next/legacy/image'
<Image src="https://cdn.example.com/a.jpg" quality={90} width={800} height={600} />
Bring this to Next.js 16. Name every change and why it's needed.
💼 Interview Notes
Common Questions
Q: What does next/image do that <img> doesn't?
Serves modern formats (AVIF/WebP) with fallbacks, generates a responsive srcset, lazy-loads off-screen images, reserves layout space to prevent CLS, and caches optimized output at the edge.
Q: Why do width and height matter if CSS controls the size?
They establish the aspect ratio so the browser can reserve space before the image loads, eliminating Cumulative Layout Shift. The rendered size still comes from CSS.
Q: What does sizes do and when is it required?
It tells the browser the image's rendered width at each breakpoint so it can pick the right srcset entry before CSS applies. Required with fill, which otherwise assumes 100vw and downloads a full-viewport-width file.
Q: When should you use priority?
On the LCP element only — the largest image visible without scrolling. It disables lazy loading and adds a preload hint. Applying it broadly cancels the benefit and delays other resources.
Q: Why must remote hosts be allow-listed?
Otherwise anyone could pass arbitrary URLs to your image optimizer and use your infrastructure as a free resizing proxy, or trigger SSRF against internal services. remotePatterns constrains protocol, host, path, and port.
Q: What did Next.js 16 change about images?
minimumCacheTTL went from 60s to 4 hours, qualities defaults to [75] only, 16 was dropped from imageSizes, local images with query strings need localPatterns, local-IP optimization is blocked by default, redirects cap at 3, and next/legacy/image plus images.domains are deprecated.
Q: How does next/font prevent layout shift?
It self-hosts the font, preloads it, and computes size-adjusted fallback metrics so the fallback occupies the same space as the real font. When the swap happens, nothing moves.
Q: Why is next/font better than a <link> to Google Fonts?
No render-blocking third-party request, no extra DNS/TLS handshake, no user data sent to Google, automatic subsetting, and the fallback-metrics trick above.
🏢 Asked at Companies
- Vercel: "A product page has an LCP of 4.2s driven by the hero image. Walk me through fixing it."
- Airbnb: "Design the image strategy for a listing page with 30 photos in a carousel."
- Shopify: "How do you stop a third party from abusing your image optimization endpoint?"
- Figma: "Explain how a font causes layout shift, and how you'd eliminate it."
📊 Visual Memory Aid
next/image DECISION TREE
Do you know the dimensions?
├── Yes → width + height
└── No → fill (parent must be position: relative + have height)
(and you MUST set sizes)
Is it visible without scrolling?
├── Yes → priority ← exactly one per page
└── No → (default: lazy)
Is it a photo?
├── Yes → next/image
└── No → SVG/icon? plain <img> or unoptimized
WHAT sizes DOES
no sizes + fill → browser assumes 100vw
4K screen → downloads 3840px file
for a 300px thumbnail 💸
sizes="(max-width: 768px) 50vw, 25vw"
→ browser picks the right srcset entry
BEFORE CSS is applied
NEXT.JS 16 IMAGE DEFAULTS
minimumCacheTTL 60s → 4 hours
qualities any → [75]
imageSizes [16, 32, …] → [32, …]
local ?query free → needs localPatterns
local IPs free → blocked (SSRF)
maximumRedirects ∞ → 3
next/legacy/image → deprecated
images.domains → deprecated (use remotePatterns)
FONT LOADING
❌ <link href="fonts.googleapis.com">
DNS → TLS → CSS → font → SWAP (layout shifts)
✅ next/font
font downloaded at BUILD time
served from your domain
fallback metrics matched → no shift
🎯 Key Takeaways
width/heightare the aspect ratio, not the display size. They exist to reserve space and eliminate layout shift; CSS still controls how big it looks.sizesis mandatory withfilland worth setting everywhere else. Without it the browser downloads a full-viewport-width image for a thumbnail.priorityon exactly one image per page — the LCP element. Marking everything priority prioritizes nothing.- Next.js 16 changed five image defaults: 4-hour cache TTL,
qualities: [75], no 16px size,localPatternsfor query strings, and blocked local IPs. Check these when upgrading. next/fontself-hosts at build time with matched fallback metrics — faster, private, and shift-free. There's no remaining reason to<link>to Google Fonts.
Next Chapter: Styling →
Practice: Build a gallery page with a priority hero, a responsive grid using fill and correct sizes, and blur placeholders. Run Lighthouse before and after adding sizes, and compare the "properly size images" audit and the total transferred bytes.