Verified against Next 16.3.5 next/image and next/font docs (2026-09-16). Portable HTML/CSS is given for other stacks.
1. next/image in Next 16: what changed
| Prop / config | Next 16 behaviour | Do |
|---|---|---|
priority |
Deprecated | Never use. Use loading="eager" + fetchPriority="high" (the docs' preferred form) or preload |
preload |
Adds in |
Use when the image isn't the first thing discovered. Don't combine it with loading/fetchPriority |
loading |
Default lazy |
Leave it lazy for everything below the fold |
sizes |
Missing means the browser assumes 100vw and Next emits only 1x/2x |
Always pass sizes unless the image is fixed-width (width={64}) |
quality |
Default 75. images.qualities defaults to [75], so other values are coerced |
Add qualities: [60, 75, 90] to config if you need them |
images.formats |
Default ['image/webp'] |
Set ['image/avif', 'image/webp'] (AVIF ~20% smaller, slower first encode) |
images.minimumCacheTTL |
Default 4 h (was 60 s) | Fine |
imageSizes |
16 removed. Default [32, 48, 64, 96, 128, 256, 384] |
Fine |
placeholder="blur" |
Automatic blurDataURL for static imports |
Adds inline base64 to the HTML. Use on the hero only if it helps |
| Static import | Content hash + immutable cache + intrinsic size |
Prefer static imports for local images |
2. Hero (LCP) image
// components/hero.tsx: Server Component
import Image from "next/image";
import hero from "@/public/hero.jpg"; // static import: hashed, sized, blur-ready
export function Hero() {
return (
<section className="relative isolate min-h-[70svh] overflow-clip">
<Image
src={hero}
alt="Product dashboard showing weekly deploys" // or alt="" if purely decorative
fill
sizes="100vw"
loading="eager"
fetchPriority="high"
className="-z-10 object-cover"
/>
<h1 className="text-display-xl text-balance">Ship on Fridays.</h1>
</section>
);
}- Mark exactly one image per route as eager + high priority. More defeats the purpose.
- Mobile hero ≤ 150 KB transfer. If it's larger, crop tighter, lower
quality, or art-direct (§4). fillrequires a positioned parent with a size (relative+ height or aspect ratio).- Never set
opacity-0on the hero waiting for an entrance animation.
Plain HTML equivalent:
<img src="/hero-1200.avif"
srcset="/hero-640.avif 640w, /hero-1200.avif 1200w, /hero-1920.avif 1920w"
sizes="100vw" width="1920" height="1080"
fetchpriority="high" decoding="async" alt="…" style="width:100%;height:auto">3. Content images: writing sizes
sizes states the rendered CSS width at each breakpoint, from widest to narrowest:
| Layout | sizes |
|---|---|
| Full-bleed | 100vw |
Inside max-w-page (1200px) container with 24px gutters |
(min-width: 1248px) 1200px, calc(100vw - 48px) |
| 3-col grid ≥1024, 2-col ≥640, else 1 | (min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw |
| Prose column (65ch ≈ 680px) | (min-width: 728px) 680px, calc(100vw - 48px) |
| Fixed avatar 40px | no sizes; width={40} height={40} |
<Image src={shot} alt="Settings page with the dark theme enabled" sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
className="h-auto w-full rounded-lg" />Id perf/image-oversized fires when a delivered image is much larger than its rendered size × DPR.
4. Art direction (different crop on mobile)
import { getImageProps } from "next/image";
export function ArtDirectedHero() {
const common = { alt: "Team at the launch event", sizes: "100vw" };
const { props: { srcSet: desktop } } = getImageProps({ ...common, width: 1920, height: 1080, quality: 75, src: "/hero-wide.jpg" });
const { props: { srcSet: mobile, ...rest } } = getImageProps({ ...common, width: 750, height: 1000, quality: 75, src: "/hero-tall.jpg", loading: "eager", fetchPriority: "high" });
return (
<picture>
<source media="(min-width: 768px)" srcSet={desktop} />
<source media="(max-width: 767px)" srcSet={mobile} />
<img {...rest} className="h-auto w-full" />
</picture>
);
}5. Video and embeds
// Background/ambient video: never the LCP, never autoplays with sound
<video className="aspect-video w-full" poster="/demo-poster.avif" preload="none" muted loop playsInline
aria-label="Demo: creating a project in 30 seconds" />Start playback with an IntersectionObserver when in view, and pause it when out of view or under prefers-reduced-motion: reduce. Autoplaying motion longer than 5 s needs a visible pause control.
YouTube facade (saves ~500 KB+ of third-party JS until click):
"use client";
import { useState } from "react";
export function YouTube({ id, title }: { id: string; title: string }) {
const [on, setOn] = useState(false);
return (
<div className="relative aspect-video w-full overflow-hidden rounded-lg bg-surface">
{on ? (
<iframe className="absolute inset-0 size-full" src={`https://www.youtube-nocookie.com/embed/${id}?autoplay=1`}
title={title} allow="autoplay; encrypted-media; picture-in-picture" allowFullScreen />
) : (
<button type="button" onClick={() => setOn(true)} className="group absolute inset-0 size-full" aria-label={`Play video: ${title}`}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={`https://i.ytimg.com/vi/${id}/hqdefault.jpg`} alt="" loading="lazy" width={480} height={360} className="size-full object-cover" />
</button>
)}
</div>
);
}Add i.ytimg.com to images.remotePatterns if you switch to next/image.
6. next/font
// app/fonts.ts
import { Geist, Geist_Mono, Instrument_Serif } from "next/font/google";
export const sans = Geist({ subsets: ["latin"], variable: "--font-geist", display: "swap" });
export const mono = Geist_Mono({ subsets: ["latin"], variable: "--font-geist-mono", display: "swap", preload: false });
export const display = Instrument_Serif({ subsets: ["latin"], weight: "400", variable: "--font-instrument", display: "swap" });// app/layout.tsx
import { sans, mono } from "./fonts";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={`${sans.variable} ${mono.variable}`}>
<body className="font-sans">{children}</body>
</html>
);
}/* app/globals.css: @theme inline is REQUIRED when tokens reference next/font variables */
@theme inline {
--font-sans: var(--font-geist), ui-sans-serif, system-ui, sans-serif;
--font-mono: var(--font-geist-mono), ui-monospace, "SFMono-Regular", Menlo, monospace;
--font-display: var(--font-instrument), ui-serif, Georgia, serif;
}Rules:
- Variable fonts: omit
weight(orweight: "variable") for one file. An array of weights produces N static files. Restricting the weight range saves nothing. - Axes cost bytes: Inter +opsz went 47 → 71 KB. Never add
axeswithout measuring. - Route-scope display or editor fonts: call the loader in the layout of the route that uses them, so other routes don't preload them.
preload: falsefor mono, italics and decorative faces. Preload at most 2 files.display: "swap"(default) for site text."optional"is valid for body text when you'd rather skip the swap than shift.- Monospace with
next/font/local: setadjustFontFallback: falseand supply aui-monospacefallback. The default Arial fallback scales to ~131% and code renders oversized until the swap. - Measured latin WOFF2: Geist 28.7 KB, Geist Mono 22.6, Inter 47.1, JetBrains Mono 39.5, Space Grotesk 21.8, Instrument Serif 20.5.
- License traps: Fontshare faces (Satoshi, General Sans, Cabinet, Clash) forbid subsetting; Berkeley Mono forbids IDE/terminal and OSS use; SF Mono can't be redistributed.
Local font:
import localFont from "next/font/local";
export const brand = localFont({
src: [{ path: "./BrandVF.woff2", style: "normal" }],
variable: "--font-brand",
display: "swap",
declarations: [{ prop: "font-feature-settings", value: "'ss01' on" }],
});7. Fonts outside Next (Astro, Vite, plain HTML)
<link rel="preload" href="/fonts/brand-latin.woff2" as="font" type="font/woff2" crossorigin>@font-face {
font-family: "Brand";
src: url("/fonts/brand-latin.woff2") format("woff2");
font-weight: 100 900; /* variable range */
font-display: swap;
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face { /* metric-matched fallback: kills the swap shift */
font-family: "Brand-fallback";
src: local("Arial");
size-adjust: 104%; ascent-override: 92%; descent-override: 24%; line-gap-override: 0%;
}
:root { --font-sans: "Brand", "Brand-fallback", system-ui, sans-serif; }Generate the override numbers with Fontaine or Capsize rather than guessing. size-adjust works everywhere. The *-override descriptors are Chromium and Firefox only (Safari ignores them).
8. Budgets recap
| Item | Budget | Id |
|---|---|---|
| Families per route | ≤ 2 (+ optional mono) | perf/too-many-fonts |
| Font files per route | ≤ 4, ≤ 2 preloaded | perf/too-many-fonts |
| Each latin WOFF2 | ≤ 50 KB | perf/font-budget |
| Total font transfer | ≤ 120 KB | perf/font-budget |
| LCP image (mobile) | ≤ 150 KB | perf/image-budget |
| Any image | ≤ 1.5× rendered width × DPR | perf/image-oversized |
| Public assets | nothing > 500 KB; delete unreferenced files | — |