Skip to content
beginner Phase 14 · Images, Fonts, and Performance

Self-Hosted Fonts with next/font

Load web fonts without layout shift or extra requests.

35m
0 problems
Topic Progress 0%

From Page Views to Build Time: How `next/font` Self-Hosts Google Fonts

Put a Google Fonts <link> in your document head and every visitor pays a fixed tax: a DNS lookup, a TLS handshake, the stylesheet, then a second round trip for the .woff2 file — all before text renders in the intended face — plus a request to Google carrying the visitor's IP and user agent. next/font moves that work from page views to builds.

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

const inter = Inter({
  subsets: ['latin'],
  display: 'swap',
})

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" className={inter.className}>
      <body>{children}</body>
    </html>
  )
}

At next build, Next downloads the family's CSS and font files, subsets them, and emits them beside your other static assets:

GET /_next/static/media/5bd9d0a6e4b70a3c.woff2
    cache-control: public, max-age=31536000, immutable

After the build, no request for a font ever leaves your domain — the docs state it plainly: no requests are sent to Google by the browser. The URL is content-addressed, so it caches forever; and the file your users receive is the one your build pinned, not whatever Google serves next quarter. For teams with GDPR obligations, the third-party request disappearing is often the argument that closes the meeting.

The two loaders differ in exactly one place — where the bytes come from:

Option next/font/google next/font/local
src — (import the family by name, Roboto_Mono for multi-word names) required; path relative to the calling file
weight required unless the family is variable required unless the file is a variable font; array for multiple files
subsets declares which slices preload (['latin']) —
display 'swap' default 'swap' default
preload true default true default
variable declares a CSS variable, e.g. '--font-sans' same

subsets matters because Google ships every family in slices — latin, cyrillic, greek, vietnamese — and preload: true injects a <link rel="preload"> only for the slices you name. Skip subsets while preloading and you get a warning and no preload. Preload scope follows the file that calls the loader: declared in the root layout, the font preloads on every route; declared in one page, only that page.

For a brand font you own, the same pipeline points at your files:

// app/fonts.ts — the .woff2 sits next to it
import localFont from 'next/font/local'

export const brand = localFont({
  src: './brand.woff2',
  weight: '400',
  style: 'normal',
  display: 'swap',
})

Variable fonts are the performance default — one file covers 100 900, so Inter({ subsets: ['latin'] }) needs no weight at all, while a static family like Roboto forces weight: '400' (or weight: ['400', '700'], style: ['normal', 'italic'] for combinations). Every extra weight is another file another preload has to justify.

A few options finish the picture. preload (default true) is what emits the <link rel="preload"> into the head, and it is scoped per subset — the coupling that makes subsets load-bearing rather than cosmetic. Variable fonts can expose extra axes beyond weight (axes: ['slnt'] on Inter), at the cost of file size, which is why the default keeps only weight. And the generated CSS — @font-face rules pointing at the hashed URL, the metric-adjusted fallback, font-display: swap — ships inside your own stylesheet, which is what deletes the render-blocking third-party stylesheet from the critical path in the first place.

Two operational facts before you wire this into CI. Fetching a Google family requires network access during next build; a locked-down runner that cannot reach fonts.googleapis.com fails the build after Next's retries, and next/font/local is the way to remove that dependency. And every call to a loader function hosts one instance of that font — calling Inter(...) in two files yields two copies in the output, which is a problem the next chapter's structure prevents.

Delivery is now solved. The visible cost of swapping the font in — the thing that actually moves your CLS number — is the subject of the next chapter.

Why the Text Doesn't Jump: Metric-Tuned Fallbacks and the `display` Values

A web font cannot paint before it downloads, so the browser must show something first. With the default font-display: swap, fallback text renders immediately and the real font replaces it when it arrives. That flash is harmless — unless the two faces have different metrics, and then lines reflow and every moved line bills your Cumulative Layout Shift score.

The metrics that hurt are the vertical ones — ascent, descent, line gap — plus average character widths. If the substitute sits taller or wider than the real face, text rewraps on swap: headings grow a line, paragraphs shift down, and a page that scored CLS 0.02 jumps to 0.3 because of a font.

next/font attacks this directly. For Google families it looks up metric data for the actual font and emits a fallback @font-face carrying overrides — size-adjust, ascent-override, descent-override, line-gap-override — tuned so the placeholder occupies nearly the same space as the incoming face. That is the mechanism behind the docs' claim that fonts load with no layout shift, and for metric-close pairs the residual movement is close to zero.

Option next/font/google default next/font/local default Effect
adjustFontFallback true 'Arial' generates (or names) the metric-adjusted fallback family
fallback none none the generic stack beneath it, e.g. ['system-ui', 'arial']
display 'swap' 'swap' the font-display value on the generated @font-face

Two failure modes are worth recognizing on sight:

  1. The safety net was switched off. adjustFontFallback: false restores stock fallback metrics, and the swap shifts the page again — on a text-heavy article that reads as a cascade of moved lines.
  2. The faces are genuinely incompatible. Overrides fix vertical metrics; they cannot make a monospace body occupy the width of a proportional one. When a heavy serif falls back to a narrow sans, choose a closer fallback stack or accept the shift deliberately.

Scale matters when you justify the work: a long article whose substitute face runs ~4% wider can accumulate CLS above 0.2 from rewraps alone — dozens of paragraphs each contributing a small distance fraction — while metric-tuned fallbacks keep the same page under 0.05. To see it attributed, record in Chrome's Performance panel and inspect the Layout Shift entries: entry.sources names the text nodes that moved, and font-swap rewraps point at paragraphs, not images. Also note the asymmetry in defaults: Google fonts get adjustFontFallback: true because Next knows their metrics from the font database, while next/font/local can only assume 'Arial' or 'Times New Roman' — nothing online describes your file. If the brand face drifts far from both, set the overrides yourself with declarations: [{ prop: 'ascent-override', value: '90%' }] or accept a measured, small shift.

display is the other lever, and its five values are worth knowing by name:

swap     (default)  fallback text renders now, swaps when the file arrives
block    invisible text for up to ~3s, then swap — a blank line is a worse sin than a shift
optional short block and NO swap period: if the font isn't ready in time, the page
         keeps the fallback for its entire life — zero CLS, brand face may never appear
fallback short block, then the fallback renders and stays
auto     browser's own guess between the above

optional is the correct answer to the brief "never move my layout" — a campaign page that would rather show system text than shift. It is the wrong answer when the brand face is the design, because first-time visitors on slow connections may never see it.

Note what the fallback does not cover: vertical metrics stop the swap shift, but a font whose lines wrap differently still rewraps paragraphs once it lands. Combine the tuned fallback with reserved space for late-rendering UI — the same instinct you applied to images in Optimizing Images with next/image — and the metric story closes.

What remains is mechanical but error-prone: which element receives the font, how your stylesheet refers to it, and how you avoid hosting the same family twice.

Applying the Family Once: `className` vs CSS Variables and the Double-Host Trap

The loader returns an object with three ways to apply the family, and the wrong choice produces either styles that silently don't resolve or the same font hosted twice.

// method 1 — class on the element (or on <html> for a global font)
<html className={inter.className}>

// method 2 — inline style object; style.fontFamily exposes the family name
<p style={inter.style}>Hello</p>

// method 3 — CSS variable, for stylesheets and Tailwind
const inter = Inter({ subsets: ['latin'], variable: '--font-inter' })
<html className={inter.variable}>
/* globals.css */
body {
  font-family: var(--font-inter);
}

The variable method is the one that composes: the class declares --font-inter on an ancestor, your stylesheet consumes it, and Tailwind plugs into the same variable — Tailwind v4 with an inline theme block, Tailwind v3 with fontFamily: { sans: ['var(--font-inter)'] } in tailwind.config.js. Either way the font-sans utility now serves a self-hosted, metric-tuned file instead of whatever the config had before.

/* Tailwind v4 */
@theme inline {
  --font-sans: var(--font-inter);
}

Hosting cost is where teams slip: every call to a loader function hosts one instance of that font. Call Inter(...) in app/layout.tsx and again in app/blog/page.tsx and the build output carries two copies of the family, both preloaded. The fix is one definitions file that everything imports:

// styles/fonts.ts — single source of truth
import { Inter, Roboto_Mono } from 'next/font/google'

export const inter = Inter({ subsets: ['latin'], display: 'swap', variable: '--font-inter' })
export const roboto_mono = Roboto_Mono({ subsets: ['latin'], variable: '--font-mono' })
// app/layout.tsx and app/blog/page.tsx both do:
import { inter } from '@/styles/fonts'

Placement decides preload budget: a font declared in the root layout preloads on every route, a font declared only in a landing page preloads only there. Declare once, import per route, and each page downloads the faces it actually renders. Keep the family count low for the same reason — a sans plus a mono covers most product UIs, and a third face has to earn an extra download on every page that uses it.

Configuration errors surface at build time, while the fix is still cheap:

  • A non-variable Google family without weight fails — the loader doesn't know which files to fetch (Roboto({ subsets: ['latin'] }) without weight: '400').
  • Multi-word families import with underscores: import { Roboto_Mono } from 'next/font/google', never Roboto Mono.
  • Omitting subsets while preload is true logs a warning and skips the preload — no error, just a font that loads late.
  • A src path is resolved relative to the file calling localFont, so app/fonts.ts and components/fonts.ts pointing at './brand.woff2' are different files. Verify which one landed in /_next/static/media/.

Apply the font where hierarchy lives — className on <html> for a global default, the variable plus CSS for per-element control — and resist layering a raw font-family in global CSS on top of the class, since specificity disputes between your stylesheet and the generated class are the classic "why isn't my font loading" ticket.

Verification is visible in DevTools: Network filtered to woff2 should show every font request same-origin under /_next/static/media with immutable caching, and nothing hitting fonts.gstatic.com. The Coverage tab (More tools → Coverage) then reports how much of each file is used — a family loaded for a single heading shows mostly dead bytes, the signal to subset harder or drop the weight. When the family does not apply at all, check the attachment point instead of the loader: a variable class on <html> with no CSS consuming the custom property renders nothing, and so does a class on a container your text does not descend from.

Fonts and images are bytes you govern at build time. Third-party scripts are bytes you don't govern at all — that trade space is Loading Scripts with next/script.

Quiz

1. You use `Inter` from next/font/google in the root layout. What happens when you run `next build`?

Question 1 options

2. A team sets `adjustFontFallback: false` and article-page CLS jumps from 0.03 to 0.22. Why?

Question 2 options

3. A campaign page's brief is: the layout must never move, even if the brand font never appears on a first visit. Which configuration satisfies it?

Question 3 options

4. Your build output contains two copies of Inter and both are preloaded. What is the cause?

Question 4 options

Flashcards

Question

What does next/font do at build time, and what happens at request time?

Answer

Build time: download the family's CSS and font files (Google) or read your files (local), subset them, emit content-hashed URLs under /_next/static/media, generate @font-face with metric-tuned fallback, and inject preload links for the named subsets. Request time: same-origin immutable fetches only — no third-party requests, no Google call — but a Google-backed build needs network access in CI.

Question

How does next/font keep layout shift near zero during a font swap?

Answer

It generates a fallback @font-face carrying size-adjust, ascent-override, descent-override, and line-gap-override tuned to the real font (adjustFontFallback defaults to true for Google fonts and 'Arial' for local), so the placeholder occupies nearly identical space. Text stays visible immediately because display defaults to swap — visible and stable, which is why the docs claim loading with no layout shift.

Question

Compare the font-display values you can pass to next/font.

Answer

swap (default): fallback shows now, swaps on load — possible rewrap. block: invisible up to ~3s then swap. optional: short block, no swap — guaranteed no late shift, font may never appear on a slow first visit. fallback: short block then fallback permanently. auto: browser heuristic. Choose optional when the brief is never move the layout; swap otherwise.

Question

className vs style vs variable — what does each give you?

Answer

className: a read-only class applying the family (and its adjusted fallback) to one element, or to <html> for a global font. style: an inline object whose fontFamily exposes the family plus fallback stack. variable: declares a CSS custom property on the element that carries it, consumed by your stylesheet — which is how Tailwind v4 (@theme inline --font-sans) and v3 (fontFamily config) plug in.

Question

What controls preload scope, and which mistakes fail the build?

Answer

Preload scope follows the calling file: root layout → every route, page → that route only (subsets must be named or preloading warns). Build failures: a static Google family without weight (Roboto needs weight: '400'), multi-word families must import as Roboto_Mono, local src paths resolve relative to the calling file, and a runner that cannot reach fonts.googleapis.com fails after Next's retries.

Revision Notes

Key Takeaways

  • 1. next/font moves font cost from every page view to every build: files are downloaded, subset, hashed, and served from your domain, so the browser makes zero third-party requests
  • 2. Swap shift comes from metric differences, and adjustFontFallback cancels it with size-adjust/ascent-override overrides tuned to the real font — that is why CLS stays near zero with display: swap
  • 3. display: swap is the default and correct for most pages; display: optional is the strict zero-CLS answer when a missing brand font is acceptable
  • 4. One loader call equals one hosted instance — centralize fonts in a definitions file and apply via className or the CSS variable that Tailwind consumes
  • 5. Fonts are owned by the build: subsets drive preloading, static families require weight, and CI needs network access (or next/font/local) to complete

Interview Tips

  • • "Why not just <link> to Google Fonts?" — every view pays DNS+TLS+CSS+file round trips and leaks IP/UA to a third party; next/font self-hosts, preloads only the subsets you name, and adds a metric-tuned fallback
  • • "How does next/font avoid layout shift?" — name the mechanism (generated fallback with size-adjust/ascent-override/descent-override/line-gap-override) plus visible text via display:swap, and offer display:optional for the strict case
  • • "How do you use a custom font with Tailwind?" — variable option, class on <html>, then @theme inline (v4) or fontFamily in the v3 config; mention one-hosted-instance discipline via a fonts definitions file
  • • "Why does next build fail on fonts?" — Google families are fetched during the build (network-blocked CI fails), static families need weight, multi-word names use underscores; self-hosting with next/font/local removes the dependency

Cheat Sheet

Self-Hosted Fonts with next/font

Google font, global:

// app/layout.tsx
import { Inter } from 'next/font/google'
const inter = Inter({ subsets: ['latin'], display: 'swap', variable: '--font-sans' })
export default function RootLayout({ children }) {
  return <html lang="en" className={inter.className}><body>{children}</body></html>
}

Local font: localFont({ src: './brand.woff2', weight: '400', display: 'swap' }) — src resolves relative to the calling file.

Options:

Option Google Local Default
weight required unless variable required unless variable file —
subsets which slices preload — none (warn if preload on)
display font-display font-display swap
preload yes yes true
adjustFontFallback auto metrics 'Arial' | 'Times New Roman' | false on
variable CSS custom property same —

display values: swap visible+may rewrap · block invisible ≤3s · optional no swap ⇒ zero CLS, may never show · fallback short block then permanent fallback · auto browser guess.

Tailwind v4: @theme inline { --font-sans: var(--font-sans); } on the variable option. Tailwind v3: fontFamily: { sans: ['var(--font-sans)'] }.

One instance per call: define in styles/fonts.ts, import everywhere — calling Inter(...) twice hosts twice.

Build-time failures: static family without weight · Roboto Mono must be Roboto_Mono · missing subsets ⇒ no preload + warning · CI without network to fonts.googleapis.com ⇒ use next/font/local.

Continue Learning