Skip to content

2.2 — Fonts

Module 2 · Lesson 2 · 🟢 Foundational · ~25 min

What you'll learn

  • Why web fonts cost you both LCP and CLS, and how to pay neither
  • next/font correctly, including variable fonts and subsetting
  • size-adjust fallback metrics — the highest‑value 10 lines in this module
  • The brand‑vs‑performance conversation, and how to win it with data

The two costs

Cost 1 — LCP delay. A text LCP element can't paint in its final form until the font loads. With font-display: block (the default for @font-face without the descriptor) the browser hides the text for up to 3 s. With swap, it paints in a fallback and swaps — no LCP delay, but…

Cost 2 — CLS. …the fallback font has different metrics, so when the real font swaps in, every line reflows. On a PDP with a long product title and description, that's a shift of the entire page below it.

The naive fixes trade one for the other:

Approach LCP CLS
font-display: block 🔴 Text invisible up to 3 s 🟢 No shift
font-display: swap 🟢 Paints immediately 🔴 Shift on swap
swap + size-adjust fallback 🟢 🟢
System fonts only 🟢 🟢

The third row is the answer, and next/font gives it to you almost for free.


next/font — what it actually does

// app/layout.tsx
import { Inter, Playfair_Display } from 'next/font/google';

const inter = Inter({
  subsets: ['latin'],
  display: 'swap',
  variable: '--font-inter',
  // Metric-compatible fallbacks. next/font computes size-adjust,
  // ascent-override, descent-override and line-gap-override for you.
  fallback: ['system-ui', 'arial'],
  adjustFontFallback: true,          // default true — do not turn this off
});

const playfair = Playfair_Display({
  subsets: ['latin'],
  display: 'swap',
  variable: '--font-playfair',
  weight: ['400', '700'],            // only what you use
});

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" className={`${inter.variable} ${playfair.variable}`}>
      <body className="font-sans">{children}</body>
    </html>
  );
}

Four things happen here that you'd otherwise do by hand:

  1. Self‑hosting. The font file is downloaded at build time and served from your own origin. No connection to fonts.gstatic.com, no extra DNS+TLS on the critical path (worth 100–300 ms), and no third‑party dependency.
  2. Automatic preload. A <link rel="preload"> for the font file used by the initial render.
  3. Automatic fallback metric adjustment (adjustFontFallback) — the CLS fix, below.
  4. No layout‑shift‑inducing FOUT for the common case.

For local/licensed brand fonts:

import localFont from 'next/font/local';

const brandSans = localFont({
  src: [
    { path: './fonts/AuroraSans-Variable.woff2', style: 'normal' },
    { path: './fonts/AuroraSans-Italic-Variable.woff2', style: 'italic' },
  ],
  display: 'swap',
  variable: '--font-brand',
  // Metrics from the actual font file — see "How to get these numbers" below
  declarations: [{ prop: 'size-adjust', value: '105.2%' }],
});

The size-adjust fix, explained

When the fallback and the web font have different metrics, the swap reflows text. size-adjust and the override descriptors scale the fallback so it occupies the same space as the real font. The swap then changes glyph shapes without moving a single line.

/* What next/font generates for you (simplified) */
@font-face {
  font-family: 'Inter Fallback';
  src: local('Arial');
  size-adjust: 107.12%;
  ascent-override: 90.20%;
  descent-override: 22.48%;
  line-gap-override: 0.00%;
}

@font-face {
  font-family: 'Inter';
  src: url('/_next/static/media/inter-latin.woff2') format('woff2');
  font-display: swap;
}

body { font-family: 'Inter', 'Inter Fallback', system-ui, sans-serif; }

How to get these numbers for a custom brand font:

# Compute metric overrides for a local font against a chosen fallback
npx fontpie ./fonts/AuroraSans-Variable.woff2 --fallback arial

# Or inspect the raw metrics yourself
npx fontkit-cli ./fonts/AuroraSans-Variable.woff2
# unitsPerEm, ascent, descent, lineGap, xHeight, and the average character width
# ratio vs the fallback give you size-adjust

Aurora's brand font swap was causing 0.09 of CLS on the PDP by itself. Adding the fallback metrics took it to 0.004. That's a 15‑minute change.

Verify it worked: record a Performance trace with network throttled to Slow 3G (so the swap is visible), and check the Experience track for layout shifts at the moment the font loads. Or in the Rendering panel, enable "Layout Shift Regions" and reload.


Reducing font bytes

Technique Typical saving Notes
WOFF2 only −30% vs WOFF Universal support; never ship TTF/OTF/EOT to the web
Subset to the scripts you need −40% to −90% subsets: ['latin'] — huge for CJK/Cyrillic families
Variable font instead of 4–6 statics −40% to −60% One file covers a weight range
Drop unused weights −25% per weight Most sites load 5 weights and use 2
Drop italic if unused −20% Check your design system for actual usage
unicode-range splitting Lazy per‑script Google Fonts does this automatically

Variable font math for Aurora:

Before: Regular 400 (28KB) + Medium 500 (29KB) + SemiBold 600 (29KB)
      + Bold 700 (28KB) + Italic 400 (27KB)                    = 141 KB
After:  AuroraSans-Variable.woff2 (weight 300-800)  62KB
      + AuroraSans-Italic-Variable.woff2            58KB        = 120 KB
        …but italic is only used in one component → lazy-load it:
                                                    critical    =  62 KB

−56% on the critical path, from a build config change.

Custom subsetting for a fixed character set (nav, logo, or a display font used only in headings) is worth doing manually:

# glyphhanger walks your rendered pages and reports exactly which glyphs you use
npx glyphhanger https://www.auroramarket.com --spider --spider-limit=50 --formats=woff2 \
  --subset=./fonts/AuroraDisplay.woff2

# Or subset to a known set with pyftsubset (fonttools)
pyftsubset AuroraDisplay.woff2 \
  --unicodes="U+0020-007E,U+00A0-00FF,U+2018-201D,U+2022,U+2013-2014" \
  --layout-features="kern,liga,calt" \
  --flavor=woff2 \
  --output-file=AuroraDisplay-subset.woff2
# 84KB → 19KB for a Latin-1 + smart-punctuation subset

Careful with aggressive subsetting on a commerce site: product names contain characters you didn't plan for — accented names, ™, ®, ½, ×, °, currency symbols, and user‑generated review text. Include Latin‑1 Supplement, General Punctuation, and your currency symbols at minimum, and have a sane fallback for anything outside the subset.


Preloading and ordering

next/font preloads automatically for fonts used in the initial render. If you're hand‑rolling:

<!-- Correct: crossorigin is REQUIRED for fonts, even same-origin.
     Without it the browser fetches the font twice. -->
<link rel="preload" href="/fonts/aurora-sans.woff2" as="font" type="font/woff2" crossorigin />

Rules:

  • Preload at most 1–2 fonts. Each one competes with your LCP image for bandwidth. If your LCP element is an image, preloading three fonts actively hurts LCP.
  • Don't preload fonts used only below the fold (a display font in the footer, an icon font in a modal).
  • Never @import fonts in CSS. It creates a serial dependency: CSS must download and parse before the font request even starts.
/* ❌ Serial: HTML → CSS → @import CSS → font. Three round trips. */
@import url('https://fonts.googleapis.com/css2?family=Inter&display=swap');

Icon fonts: don't

Icon fonts are a legacy pattern with three real costs: a render‑blocking font file, invisible icons during load (or boxes), and accessibility problems. They also can't be tree‑shaken — you ship 200 glyphs to use 12.

// ❌ 48KB icon font for 12 icons, blocks render, breaks with font-display: swap
<i className="icon icon-cart" />

// ✅ Inline SVG: ~300 bytes each, no extra request, styleable, accessible,
//    and server-rendered (no client JS)
export function CartIcon(props: React.SVGProps<SVGSVGElement>) {
  return (
    <svg viewBox="0 0 24 24" width="24" height="24" fill="none"
         stroke="currentColor" strokeWidth="1.5" aria-hidden="true" {...props}>
      <path d="M3 3h2l2.4 12.3a2 2 0 0 0 2 1.7h7.7a2 2 0 0 0 2-1.6L21 8H6" />
      <circle cx="9" cy="20" r="1.5" /><circle cx="18" cy="20" r="1.5" />
    </svg>
  );
}

For a large icon set, use an SVG sprite or a build‑time icon component generator — but never a font, and never a barrel import of an icon library (see 4.3 Dependency diet, where lucide-react's barrel costs Aurora 90 KB).


The brand conversation

Design will push back on font changes. Bring numbers, not opinions:

Argument Data to bring
"We need all 6 weights" Usage audit: grep the codebase for font-weight/font- classes. Usually 2–3 are used
"The brand font must load first" It does — swap + metric fallback means users see correct layout immediately and correct glyphs ~200 ms later
"The fallback looks wrong" Show a screenshot at the moment of swap with metrics matched. Most people can't tell
"We can't subset, product names vary" Correct — subset to Latin‑1 + punctuation + currency, not to ASCII
"Just use font-display: block" Show them the 3 s blank text on Slow 3G on a real phone

The winning frame: you are not removing the brand font, you are making it arrive without costing the user a blank screen or a page jump. Nobody argues with that.


Common mistakes

Mistake Cost
Google Fonts via <link> to fonts.gstatic.com Extra DNS+TLS on the critical path (100–300 ms)
@import in CSS Serial dependency chain
font-display: block (or omitting the descriptor) Up to 3 s of invisible text
swap without metric fallbacks 0.05–0.15 CLS
Preloading 4 fonts Bandwidth contention with the LCP image
Missing crossorigin on font preload Font downloaded twice
Shipping 6 static weights 100+ KB that a variable font does in 60
Icon fonts Blocking request, invisible icons, a11y issues
Loading the italic variable font for one component Lazy‑load it via a scoped @font-face instead

Lab 2.2 — Font audit

  1. Inventory: DevTools → Network → Font. How many files, total bytes, from which origins?
  2. Usage: grep for actual weight/style usage.
    rg -o 'font-(thin|light|normal|medium|semibold|bold|extrabold|black)' --no-filename | sort | uniq -c | sort -rn
    rg -o 'fontWeight:\s*[0-9]+' --no-filename | sort | uniq -c | sort -rn
    
    Compare to what you load. Delete the difference.
  3. CLS check: throttle to Slow 3G, enable Rendering → Layout Shift Regions, reload. If the page jumps when text sharpens, you're missing metric fallbacks.
  4. Migrate to next/font with display: 'swap' and adjustFontFallback: true.
  5. Consolidate to variable fonts; drop unused weights and italics.
  6. Re‑measure LCP and CLS. Expect CLS −0.03 to −0.10 and LCP −100 to −400 ms if you were using a third‑party font host.

Checklist

  • Fonts self‑hosted via next/font (no third‑party font origin on the critical path)
  • display: swap everywhere
  • Metric‑compatible fallbacks in place (adjustFontFallback or explicit size-adjust)
  • WOFF2 only, subset to the scripts you need + Latin‑1 + punctuation + currency
  • Variable fonts where you use 3+ weights
  • At most 1–2 preloaded fonts, only ones used above the fold
  • crossorigin on any manual font preload
  • No icon fonts
  • Font CLS verified as ~0 with a throttled trace

Next: 2.3 Third‑party scripts