Skip to content

Images and Fonts

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.

app/page.tsx
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.

next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
images: {
remotePatterns: [
{ protocol: 'https', hostname: 'cdn.example.com', pathname: '/**' },
],
},
}
export default nextConfig

Since 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 string
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
images: {
localPatterns: [{ pathname: '/assets/**', search: '?v=1' }],
},
}
export default nextConfig

Omitting 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:

next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
images: {
qualities: [25, 50, 75, 100],
},
}
export default nextConfig

If 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 Request

minimumCacheTTL 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 OK
Cache-Control: public, max-age=14400, must-revalidate

14400 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:

next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
images: { minimumCacheTTL: 60 }, // pre-16 behavior
}
export default nextConfig

sizes, 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 prop
import 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 width
import 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 priority
import 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.

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 sharp
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
images: { unoptimized: true },
}
export default nextConfig

unoptimized is also the right call per-image for content that never benefits from optimization — tiny icons under 1KB, SVGs, or animated GIFs.

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 Google
import { 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"]
From source asset to a stable, fast page

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.

What you getBenefitCost
sizes set correctlyBrowser downloads the smallest sufficient candidateOne more prop to keep in sync with your actual CSS layout
qualities allowlist (default [75])Predictable optimizer cost, no arbitrary-quality abuseMust add every quality value your design actually uses
minimumCacheTTL: 14400 (default)Fewer revalidations, lower optimizer costStale image can persist up to 4h if the upstream Cache-Control is missing
unoptimized: trueWorks anywhere, even without sharpNo resizing, no format conversion — you own that cost elsewhere
preload / fetchPriority="high"Explicit control over which image gets prioritizedWrong image marked this way delays the real LCP element
  • “fill without sizes just works, since the box has a fixed CSS width.” Next.js does not read your CSS — it only sees the sizes prop (or its 100vw default). A visually small box with no sizes still requests full-viewport-sized candidates.
  • “priority is still the modern way to mark the LCP image.” It is deprecated as of Next.js 16 in favor of preload, and fetchPriority/loading="eager" for the multi-candidate case.
  • “An invalid quality prop just gets ignored.” It’s coerced to the closest allowed value in images.qualities when used through <Image> — but a direct request to the optimizer endpoint with a disallowed quality is rejected with 400.
  • “If sharp is missing, image optimization just fails loudly so you’ll notice.” It doesn’t — it silently serves the original file unresized. Set unoptimized: true explicitly if that’s the deploy you actually want.

When a generated snippet touches next/image, check for these before trusting it:

  • Does a fill image have a sizes prop that reflects the actual rendered width, not just “whatever compiles”? No sizes on fill silently defaults to 100vw.
  • Does it use priority where preload (or fetchPriority/loading="eager" for multi-candidate LCP) is the current API?
  • Does a remote or query-stringed local image lack the matching images.remotePatterns / images.localPatterns entry it needs to even load?
  • Does a quality prop assume any 1–100 value works, ignoring that images.qualities defaults to [75] only in Next.js 16?
  • Does the code assume sharp is present without either installing it or explicitly setting unoptimized: true for a sharp-less target?

🐛 Spot the bug

app/product/[id]/page.tsx
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).

A fill image has no sizes prop. What does Next.js assume?
What replaced the priority prop in Next.js 16?
What is the default value of images.qualities in Next.js 16, and what happens if you request the optimizer endpoint directly with a quality outside it?
What did the default minimumCacheTTL change to in Next.js 16, and how was it confirmed?
On a 400px-wide viewport, roughly what does the browser download for this box, and why?
<div style={{ position: 'relative', width: 300, height: 200 }}>
  <Image src="/hero.jpg" alt="" fill style={{ objectFit: 'cover' }} />
</div>
You cannot install sharp on your deploy target. What is the correct, honest way to handle next/image there?