Images and Fonts
The idea in one sentence
Section titled “The idea in one sentence”next/image resizes, converts to modern formats, and lazy-loads your images while reserving their space, and next/font self-hosts your fonts at build time — so the page loads fast and never shifts.
next/image resizes, converts, and lazy-loads
Section titled “next/image resizes, converts, and lazy-loads”Image is a drop-in replacement for <img> that optimizes on demand. It generates a resized version per device, serves WebP when the browser supports it (AVIF too, if you add it to images.formats — the default formats list is ['image/webp'] only), and lazy-loads anything below the fold automatically.
When you import a local file, Next.js reads its intrinsic dimensions at build time, so width and height are filled in for you.
import Image from 'next/image'import hero from './hero.png'
export default function Page() { return <Image src={hero} alt="Product hero shot" />}Remote images need remotePatterns, and local images with a query string need localPatterns
Section titled “Remote images need remotePatterns, and local images with a query string need localPatterns”Optimizing an arbitrary external URL would be an open proxy, so Next.js refuses unless you allowlist the host. Declare images.remotePatterns in next.config and be as specific as you can.
import type { NextConfig } from 'next'
const nextConfig: NextConfig = { images: { remotePatterns: [ { protocol: 'https', hostname: 'cdn.example.com', pathname: '/**' }, ], },}
export default nextConfigSince Next.js 16 the same allowlisting principle also applies to local images that carry a query string (/assets/photo?v=1) — this used to be optimized for free, but an unrestricted query string on a local path could be abused for enumeration attacks, so it is now a build/runtime restriction: you must add the exact pattern to images.localPatterns, search included.
// next.config.ts — required since 16 for local images with a query stringimport type { NextConfig } from 'next'
const nextConfig: NextConfig = { images: { localPatterns: [{ pathname: '/assets/**', search: '?v=1' }], },}
export default nextConfigOmitting search allows any query string on that path — pin it to an exact value unless you genuinely need a wildcard.
qualities and minimumCacheTTL: two allowlists that changed defaults in 16
Section titled “qualities and minimumCacheTTL: two allowlists that changed defaults in 16”Two images config fields quietly narrowed their defaults in Next.js 16, and both are security-shaped, not just performance knobs.
qualities used to accept any quality value; as of 16 it is a required allowlist, defaulting to [75] only:
import type { NextConfig } from 'next'
const nextConfig: NextConfig = { images: { qualities: [25, 50, 75, 100], },}
export default nextConfigIf an <Image quality={80}> prop doesn’t match an allowed value, Next.js silently coerces it to the closest one in the list. But hit the optimizer endpoint directly with a disallowed quality and it refuses outright — proven against the probe (a fresh next start, no qualities override, so the default [75] allowlist is active):
$ curl -sD - -o /dev/null "http://localhost:3181/_next/image?url=%2Fhero.jpg&w=640&q=80"HTTP/1.1 400 Bad RequestminimumCacheTTL changed from 60 seconds to 4 hours (14400 seconds) as of Next.js 16 — confirmed by reading the real Cache-Control header the optimizer sends on a cache miss:
$ curl -sD - -o /dev/null "http://localhost:3181/_next/image?url=%2Fhero.jpg&w=640&q=75"HTTP/1.1 200 OKCache-Control: public, max-age=14400, must-revalidate14400 is exactly the new default — the framework favors a durable cache over frequent revalidation now, on the theory that most upstream images without their own Cache-Control header rarely change. Override it per project if you need fresher images:
import type { NextConfig } from 'next'
const nextConfig: NextConfig = { images: { minimumCacheTTL: 60 }, // pre-16 behavior}
export default nextConfigsizes, srcset, and downloading an oversized image
Section titled “sizes, srcset, and downloading an oversized image”sizes tells the browser how wide the image will actually render at different breakpoints, so it can pick the smallest sufficient candidate out of the srcset Next.js generates. Skip it on a fill image and Next.js defaults sizes to 100vw — the browser then assumes the image is as wide as the viewport, no matter how small the box actually is.
Proven in the probe with a 1600×1600 source image inside a 300px-wide fill box:
// app/sizes-wrong/page.tsx — no sizes propimport Image from 'next/image'
export default function Page() { return ( <div style={{ width: 300, position: 'relative', height: 200 }}> <Image src="/hero.jpg" alt="wrong sizes demo" fill style={{ objectFit: 'cover' }} /> </div> )}The rendered HTML shows exactly what the browser has to choose from:
sizes="100vw"srcSet="/_next/image?url=%2Fhero.jpg&w=640&q=75 640w, /_next/image?url=%2Fhero.jpg&w=750&q=75 750w, ... up to /_next/image?url=%2Fhero.jpg&w=3840&q=75 3840w"Only the deviceSizes tier (640, 750, 828, 1080, 1200, 1920, 2048, 3840) is offered — the smaller imageSizes tier (32...384) is dropped entirely, because Next.js only includes those small candidates when sizes indicates the image is less than full viewport width. On a 400px-wide viewport, sizes="100vw" resolves to 400px, so the browser picks the smallest candidate that still covers it: 640w — more than 4× the pixel area the 300px box actually needs.
Add the real sizes value and the small tier comes back, and the browser downloads something much closer to what it displays:
// app/sizes-right/page.tsx — sizes matches the actual rendered widthimport Image from 'next/image'
export default function Page() { return ( <div style={{ width: 300, position: 'relative', height: 200 }}> <Image src="/hero.jpg" alt="right sizes demo" fill sizes="300px" style={{ objectFit: 'cover' }} /> </div> )}sizes="300px"srcSet="... 32w, 48w, 64w, 96w, 128w, 256w, 384w, 640w, ..."Now the full imageSizes tier is present, and a 400px viewport picks 384w for a 1x display — the wrong-sizes version was downloading over 2.7× the pixel width (and roughly 7× the pixel area) it needed.
priority is deprecated in favor of preload; loading and fetchPriority for the rest
Section titled “priority is deprecated in favor of preload; loading and fetchPriority for the rest”As of Next.js 16, the priority prop is deprecated — the docs bundled with this exact version say so directly: “Starting with Next.js 16, the priority property has been deprecated in favor of the preload property in order to make the behavior clear.” priority still works today, but new code should reach for preload instead.
// app/page.tsx — current API: preload replaces priorityimport Image from 'next/image'
export default function Hero() { return <Image src="/hero.jpg" alt="Hero" width={1600} height={900} preload />}Use preload on the one image that is your Largest Contentful Paint (LCP) element — typically an above-the-fold hero image. When more than one image could be the LCP element depending on viewport (a common responsive-hero case), the docs recommend loading="eager" or fetchPriority="high" instead of preload, since preload inserts an unconditional <link> in <head> regardless of which image the browser ends up needing.
// Multiple viewport-dependent hero candidates: fetchPriority, not preload<Image src="/hero-mobile.jpg" alt="" width={400} height={600} fetchPriority="high" />loading still controls lazy-vs-eager the same way it always has — lazy (the default) defers until the image nears the viewport, eager loads immediately regardless of position.
unoptimized: self-hosting without sharp
Section titled “unoptimized: self-hosting without sharp”Image Optimization needs sharp on the server. Proven in the probe: removing sharp from node_modules and requesting /_next/image did not error — it silently served the original, full-size file untouched (a 1600×1600 source came back as 1600×1600, ignoring the requested w=750), instead of failing loudly or converting formats. That silent fallback is worse than an explicit choice: if a deploy target genuinely can’t install sharp, set images.unoptimized: true yourself so the behavior is documented in config, not discovered by reading response headers in production.
// next.config.ts — intentional opt-out, e.g. static export or a host without sharpimport type { NextConfig } from 'next'
const nextConfig: NextConfig = { images: { unoptimized: true },}
export default nextConfigunoptimized is also the right call per-image for content that never benefits from optimization — tiny icons under 1KB, SVGs, or animated GIFs.
next/font self-hosts at build time
Section titled “next/font self-hosts at build time”next/font downloads the font files at build time and serves them from your own origin. There is no runtime request to Google, and because the font metrics are known ahead of time it reserves the right space and produces zero layout shift.
// app/layout.tsx — self-hosted, zero layout shift, no request to Googleimport { Inter } from 'next/font/google'import localFont from 'next/font/local'
const inter = Inter({ subsets: ['latin'], display: 'swap' })const brand = localFont({ src: './brand.woff2' })
export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="en" className={inter.className}> <body className={brand.className}>{children}</body> </html> )}flowchart LR Src[Source asset] --> Build[Build step] Build --> Opt["Resized, WebP-only by default, self-hosted font"] Opt --> Reserve["width + height (or sizes) reserve space"] Reserve --> Page["No layout shift, fast LCP"]
Mental Model
Section titled “Mental Model”Think of next/image as a vending machine with a fixed set of pre-cut sizes (deviceSizes + imageSizes), not a photocopier that prints any size on demand. sizes is you telling the machine which slot you’ll actually use; skip it and the machine assumes you want the biggest slot “just in case,” because that’s the only safe assumption for a full-width image. qualities and minimumCacheTTL are the machine’s own safety rules — a fixed quality menu instead of an unbounded dial, and a longer default shelf life so it doesn’t have to re-stock every minute.
Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
sizes set correctly | Browser downloads the smallest sufficient candidate | One more prop to keep in sync with your actual CSS layout |
qualities allowlist (default [75]) | Predictable optimizer cost, no arbitrary-quality abuse | Must add every quality value your design actually uses |
minimumCacheTTL: 14400 (default) | Fewer revalidations, lower optimizer cost | Stale image can persist up to 4h if the upstream Cache-Control is missing |
unoptimized: true | Works anywhere, even without sharp | No resizing, no format conversion — you own that cost elsewhere |
preload / fetchPriority="high" | Explicit control over which image gets prioritized | Wrong image marked this way delays the real LCP element |
Common Misconceptions
Section titled “Common Misconceptions”- “
fillwithoutsizesjust works, since the box has a fixed CSS width.” Next.js does not read your CSS — it only sees thesizesprop (or its100vwdefault). A visually small box with nosizesstill requests full-viewport-sized candidates. - “
priorityis still the modern way to mark the LCP image.” It is deprecated as of Next.js 16 in favor ofpreload, andfetchPriority/loading="eager"for the multi-candidate case. - “An invalid
qualityprop just gets ignored.” It’s coerced to the closest allowed value inimages.qualitieswhen used through<Image>— but a direct request to the optimizer endpoint with a disallowed quality is rejected with400. - “If
sharpis missing, image optimization just fails loudly so you’ll notice.” It doesn’t — it silently serves the original file unresized. Setunoptimized: trueexplicitly if that’s the deploy you actually want.
Reading AI-generated code
Section titled “Reading AI-generated code”When a generated snippet touches next/image, check for these before trusting it:
- Does a
fillimage have asizesprop that reflects the actual rendered width, not just “whatever compiles”? Nosizesonfillsilently defaults to100vw. - Does it use
prioritywherepreload(orfetchPriority/loading="eager"for multi-candidate LCP) is the current API? - Does a remote or query-stringed local image lack the matching
images.remotePatterns/images.localPatternsentry it needs to even load? - Does a
qualityprop assume any 1–100 value works, ignoring thatimages.qualitiesdefaults to[75]only in Next.js 16? - Does the code assume
sharpis present without either installing it or explicitly settingunoptimized: truefor a sharp-less target?
🐛 Spot the bug
import Image from 'next/image'
export default function ProductGrid() {return ( <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 8 }}> {[1, 2, 3].map((id) => ( <div key={id} style={{ position: 'relative', height: 200 }}> <Image src={`/products/${id}.jpg`} alt="" fill style={{ objectFit: 'cover' }} /> </div> ))} </div>)}Show the answer
Each grid cell renders at roughly one-third of the viewport width, but there is no sizes prop on any of these fill images — Next.js defaults sizes to 100vw, so every one of the three images requests a full-viewport-width candidate from the deviceSizes tier (640w and up), even though each cell is only ~33vw wide. Proven in the probe: a 300px box with no sizes served a minimum 640w candidate, while the same box with sizes="300px" served 384w — the fix here is sizes="(max-width: 768px) 100vw, 33vw" (or the fixed pixel width if the grid is not responsive).