next/font and CSS strategies: self-hosted fonts, zero-shift swaps, zero-runtime styles
next/font downloads Google fonts at build and self-hosts them — no runtime request to Google — with a size-adjusted fallback so the swap shifts nothing. Subsets and variable fonts set the byte budget; Tailwind and CSS Modules stay zero-runtime, CSS-in-JS drags a client bundle.
A B2B marketing site gets two unrelated complaints in one week. Legal forwards a letter: a visitor cites the 2022 Munich court ruling that embedding Google Fonts from Google’s CDN transmits visitor IPs to Google without consent — a GDPR problem the team didn’t know it had, baked into one <link href="https://fonts.googleapis.com/..."> tag. Meanwhile product flags the field data: CLS at 0.24, far past the 0.1 threshold. The filmstrip shows why — headlines render in Arial, then 800 ms later the brand font lands and every line of text reflows because the two fonts have different widths and line heights; on slow connections the pricing table visibly jumps as users reach to click. Both bugs have the same one-commit fix: next/font downloads the font at build time, serves it from the site’s own origin (no request to Google ever leaves the visitor’s browser), and generates a metric-adjusted fallback so the moment of swap moves nothing at all.
What the font loader does at build time
next/font/google is not a runtime integration with Google Fonts — it’s a build-time downloader. When you call a font loader at module scope, the build fetches the font files from Google once, subsets them, and emits the .woff2 files into /_next/static/media with immutable cache headers, alongside a generated @font-face declaration inlined into your CSS. In production, the visitor’s browser talks only to your origin: no DNS lookup for fonts.googleapis.com, no third-party connection setup (which alone costs a few hundred milliseconds on mobile), no IP address shipped to Google — which is what settles the GDPR exposure from the Hook. next/font/local does the same packaging for fonts you own.
// app/layout.tsx — loader call MUST be a module-scope const, one per font
import { Inter } from "next/font/google";
const inter = Inter({
subsets: ["latin"], // required for Google fonts: ship only the glyphs you use
display: "swap", // show fallback immediately, swap when ready
variable: "--font-sans" // expose as a CSS variable for Tailwind/CSS use
});
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={inter.variable}>
<body>{children}</body>
</html>
);
}The module-scope rule is load-bearing: each loader call is one font instance hoisted and deduplicated at build. Call it inside a component body or in many files with different options and you mint multiple instances — duplicate @font-face blocks and duplicate preloads. The convention that scales: declare every font in one file, export the instances, and attach them once in the root layout via className or variable.
Tradeoff: build-time fetching means your build now depends on reaching Google’s servers (a CI box without egress fails the build), and font updates arrive only when you rebuild. In exchange you get hermetic production behavior: the font version is pinned, served from your CDN edge alongside your CSS, and immune to third-party outages.
Killing font CLS: size-adjust and fallback metrics
font-display: swap shows the fallback font immediately, then swaps in the web font when it arrives. That’s right for performance — text is readable from first paint — but the swap is where CLS comes from: Arial and your brand font disagree about glyph widths, x-height, ascent and descent, so when the swap happens every paragraph re-wraps and the page reflows. Text-heavy pages routinely pick up 0.1–0.25 CLS from this one mechanism, blowing the 0.1 budget on typography alone.
next/font’s answer is adjustFontFallback (on by default): at build it computes the metric overrides — size-adjust, ascent-override, descent-override, line-gap-override — that reshape a local fallback (Arial for sans, Times for serif) to occupy exactly the same space as the web font, and emits that adjusted fallback as a second @font-face. The browser renders the adjusted fallback first; when the real font lands, glyphs change shape but no line wraps differently and nothing moves. The swap becomes geometrically invisible: the user sees a font change, the layout engine sees nothing. This is the difference between “we use font-display: swap” and “our font swap contributes 0.00 to CLS” — the first is a loading strategy, the second requires the metric math next/font does for you.
A team moves a font from a fonts.googleapis.com link tag to next/font/google. In production, what does the visitor's browser now request, and from where?
Subsets, weights, and variable fonts: the byte budget
When you reach for a web font, you often grab the whole family without thinking about what you’re actually downloading. Before you ship a 200 KB font to a site that renders only English, consider what a subset and variable font can cut.
A font family’s full character set is enormous — Latin plus Cyrillic plus Greek plus Vietnamese — and most sites need a fraction of it. The subsets option ships only the ranges you declare: a single static weight of a workhorse sans subset to Latin is roughly 15–30 KB of woff2, while the unsubset file can run several times that. For a bilingual site, declare both subsets explicitly (subsets: ["latin", "cyrillic"]) — the browser downloads per-subset files only when the page actually uses those glyphs, via unicode-range.
Weights are the second axis. Each static weight is its own file, so weight: ["400", "500", "600", "700"] is four downloads. A variable font packs the whole weight axis into one file — typically 40–100 KB for a Latin subset — which beats static files as soon as you use more than two or three weights, and unlocks intermediate values for free. The honest accounting cuts the other way too: if your design genuinely uses only regular and bold, two static files at ~20 KB each undercut the variable file. next/font defaults Google variable fonts to the variable axes when available; check the network panel, not the vibes, when deciding.
CSS strategies and what they do to your bundles
Fonts are half the typography story; the CSS that applies them has its own bundle behavior. Tailwind is zero-runtime by construction: a build-time scan of your source generates one static stylesheet of just the utilities you used — typically 10–50 KB total even on large apps, served once, cached forever, no JavaScript shipped. CSS Modules are also zero-runtime: class names are hashed at build for scoping, and the CSS is code-split alongside the route chunks that import it, so a page only pulls the styles its tree uses. Both compose cleanly with next/font via the CSS variable pattern from the snippet — Tailwind’s fontFamily config simply references var(--font-sans).
The one that fights the platform is runtime CSS-in-JS. Libraries like styled-components compute styles in the browser, which means every styled component must be a Client Component, the style engine (~12 KB+ gzipped before your first style) ships in the bundle, and Server Components — which never run in the browser — can’t use it at all without a registry workaround for streaming. Failure mode: a team migrates to the App Router, keeps its styled-components design system, and discovers every leaf of the UI kit now needs 'use client' — the styling choice silently dictated the server/client architecture. Choosing zero-runtime CSS isn’t a style preference in the App Router; it’s what keeps your component tree free to stay on the server.
With font-display: swap, a brand font replaces the fallback 800 ms after first paint and field CLS attributes 0.18 to text shifting at that moment. What is next/font's actual mechanism for fixing this?
- 01What happens at build time versus runtime with next/font/google, and why does the module-scope rule matter?
- 02How does next/font make font-display: swap contribute zero CLS, and when does a variable font beat static weights?
next/font turns fonts from a runtime third-party dependency into build artifacts. The loader call — module-scope, once per font, shared everywhere — downloads from Google during next build, subsets to the declared unicode ranges, and emits woff2 into /_next/static with immutable caching; the visitor’s browser never contacts a Google domain, which simultaneously deletes the third-party connection setup from the critical path and the GDPR exposure of shipping visitor IPs to Google. The layout-shift fix is the part teams underestimate: font-display swap paints fallback text immediately, and the swap reflows the page because fallback and web font have different metrics — adjustFontFallback computes size-adjust, ascent-, descent- and line-gap-overrides at build and emits a fallback that occupies exactly the web font’s geometry, so the swap changes glyph shapes and moves nothing, keeping typography’s CLS contribution at zero against the 0.1 budget. Bytes are governed by two axes: subsets (ship latin and cyrillic explicitly and let unicode-range gate the downloads; a subset static weight is ~15–30 KB) and weights, where a variable font’s single 40–100 KB file beats per-weight files once you use more than two or three weights. On the CSS side, Tailwind and CSS Modules are zero-runtime — one cached static stylesheet, or route-chunked scoped CSS — and integrate with next/font through the CSS variable pattern, while runtime CSS-in-JS ships its engine in the client bundle and forces every styled component to be a Client Component, quietly dictating your server/client architecture from the styling layer. Now when you see a CLS report blaming text, reach for the adjustFontFallback setting before anything else — and when a GDPR audit flags a third-party font request, a single next/font migration closes it.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.