Skip to content

Reference · speed

Images and fonts: paste-ready recipes

speed/references/images-fonts.md227 linesupdated 16 Sept 2026

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

tsx
// 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).
  • fill requires a positioned parent with a size (relative + height or aspect ratio).
  • Never set opacity-0 on the hero waiting for an entrance animation.

Plain HTML equivalent:

html
<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}
tsx
<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)

tsx
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

tsx
// 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):

tsx
"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

ts
// 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" });
tsx
// 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>
  );
}
css
/* 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 (or weight: "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 axes without 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: false for 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: set adjustFontFallback: false and supply a ui-monospace fallback. 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:

ts
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)

html
<link rel="preload" href="/fonts/brand-latin.woff2" as="font" type="font/woff2" crossorigin>
css
@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

Sources