Self-Hosting Web Fonts for Performance: next/font Done Right

Photo by Marcus dePaula

Photo by Marcus dePaula
Fonts are the most underrated line item in a performance budget. I have watched Lighthouse runs on this very portfolio where the largest contentful paint was not blocked by an image or a JavaScript bundle — it was waiting on a typeface. Fonts sit in the critical rendering path, they affect layout shift, and the default way most sites still load them, a stylesheet link to a third-party CDN, is one of the slowest possible options.
The fix is to self-host, subset, and pick a sane display strategy — and in Next.js the next/font module does most of this for you, if you understand what it is actually doing. This post walks the whole chain: why third-party font CSS is a tax, how FOIT and FOUT actually work, what next/font generates under the hood, and where subsetting and variable fonts earn their keep.
The classic Google Fonts embed costs you at minimum a connection to a stylesheet host and a second connection to the font file host: DNS lookups, TCP handshakes, TLS negotiations — before a single glyph downloads. On a fast desktop connection that overhead hides; on the mid-range Android over cellular that dominates Indonesian traffic, it routinely adds hundreds of milliseconds to first text render. And because the font URLs live inside the external CSS, the browser cannot even start the font download until that stylesheet has arrived and parsed.
Self-hosting collapses all of that into your existing origin connection. The font files ride the same HTTP/2 or HTTP/3 connection as your HTML and CSS, they can be preloaded with full confidence, and there is no third-party availability or privacy question. This last point is not academic: shipping visitor IPs to a third-party font CDN is exactly the kind of data flow that privacy regulations — including Indonesia's UU PDP — make you accountable for. Self-hosted fonts simply remove the conversation.
While a font downloads, the browser has to render something. The font-display descriptor decides what, using a timeline of two windows: a block period where text is invisible, and a swap period where fallback text shows but will be swapped once the font arrives. Every value is just a different configuration of those two windows:
| font-display | Behavior | My verdict |
|---|---|---|
| block | Short block period, then infinite swap. Text invisible up to around three seconds — the classic FOIT. | Avoid. Invisible text on slow networks is the worst outcome for users and for LCP. |
| swap | Near-zero block, infinite swap. Fallback text renders immediately, swaps whenever the font lands — FOUT. | My default for brand-critical type. Pair with a tuned fallback to keep the swap from shifting layout. |
| fallback | Near-zero block, short swap of about three seconds. If the font misses the window, this page view keeps the fallback. | Good middle ground for body text where late swaps feel jarring. |
| optional | Near-zero block, no swap. The font is used only if it is already available, otherwise this view ships the fallback. | The performance-purist option web.dev recommends — zero swap-induced shift, at the cost of first-visit branding. |
There is no free lunch: you are choosing between invisible text, a visible reflow, or occasionally showing the fallback font. The honest engineering decision is to make that trade explicitly per project instead of inheriting whatever a copy-pasted embed code decided for you.
next/font is not a wrapper around the Google Fonts embed — it is a build-time font pipeline. When you import a font from next/font/google, at build time Next.js:
// app/fonts.ts — one instance, imported everywhere
import { Inter } from "next/font/google"
import localFont from "next/font/local"
export const inter = Inter({
subsets: ["latin"], // only preload the glyphs you use
display: "swap", // text visible immediately
variable: "--font-inter" // expose as CSS variable for Tailwind
})
// Brand font you own the files for:
export const brand = localFont({
src: [
{ path: "./brand-var.woff2", weight: "100 900", style: "normal" },
],
display: "swap",
adjustFontFallback: "Arial", // metric-tuned fallback against CLS
variable: "--font-brand",
})
// app/layout.tsx
// <html className={inter.variable + " " + brand.variable}>
// tailwind.config.js
// fontFamily: { sans: ["var(--font-inter)"], display: ["var(--font-brand)"] }The pattern I use on every project is a single fonts file exporting each font once, with CSS variables wired into Tailwind. Calling the font loader in multiple files instantiates the font multiple times — one definition file, imported everywhere, keeps both the bundle and the preload tags deduplicated.
Resist the urge to manually preload every weight you self-host. Preload bypasses the browser's own prioritization, and three preloaded font files compete with your LCP image for bandwidth during the most contested milliseconds of the page load. Preload the one or two files above-the-fold text actually needs; let the rest load on demand.
Format first: WOFF2 is the only format worth shipping in 2026 — per web.dev it compresses about thirty percent better than WOFF, and every browser you care about supports it. If your design team hands you a TTF, convert it before it touches production.
Subsetting is the bigger lever. A full multilingual font carries Cyrillic, Greek, Vietnamese and hundreds of glyphs your site never renders. Declaring subsets latin in next/font trims Google fonts automatically; for local brand fonts, run them through a subsetter like pyftsubset and keep only the unicode ranges you serve. On a client project the headline font dropped from 218 KB to 34 KB with a latin subset — that is not micro-optimization, that is an entire image worth of bandwidth handed back to the critical path. Indonesian text is fully covered by the latin subset, which makes this an easy call for sites serving Bahasa Indonesia.
Variable fonts pack a continuous design space — weight, width, slant — into a single file. They changed my default font budget math:
Fewer requests, often less total weight
Regular plus medium plus semibold plus bold as static files is four downloads. One variable file covering the 100 to 900 weight range is a single request and usually smaller than three static cuts combined.
Weights become a free design decision
With static fonts, every new weight a designer wants is another network cost conversation. With a variable font, weight 450 or 620 costs nothing extra — the axis is already in the file.
First-class in next/font
Variable Google fonts are the recommended path: skip the weight option entirely or pass a range like 100 900, and next/font serves the variable file. Extra axes such as slnt are opt-in via the axes option to keep size down.
Watch the axis bloat
Each additional axis grows the file. An optical-size plus width plus weight font can outgrow the static cuts you actually use. Subset the axes the same way you subset glyphs: ship only what the design system uses.
Fonts deserve the same engineering attention as images and JavaScript, and they usually get none. The stack that works: self-host via next/font, one variable WOFF2 file per family, latin subset, display swap with the automatic metric-compatible fallback, and preload restraint. On this portfolio that combination took fonts completely off the LCP critical path — the text renders instantly in a tuned system fallback and the brand font slides in without a visible jump. Boring, measurable, and worth doing on every project.
Open DevTools, switch the Network panel to Font, and reload with cache disabled. If you see more than two font files before first paint, or any request to a fonts CDN you do not own, you have found this week's easiest performance win.
Sources and further reading