---
title: "Images and fonts: paste-ready recipes (speed)"
description: "Verified against Next 16.3.5 next/image and next/font docs (2026-09-16). Portable HTML/CSS is given for other stacks."
canonical: https://void-design.vercel.app/docs/speed/images-fonts
lastModified: 2026-09-16
---

# Images and fonts: paste-ready recipes

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 `<link rel="preload">` in `<head>` | 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

- https://nextjs.org/docs/app/api-reference/components/image
- https://nextjs.org/docs/app/api-reference/components/font
- https://web.dev/articles/optimize-lcp · https://web.dev/articles/css-size-adjust
- https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/img#sizes
