Skip to content
Chapter 34Lesson 4

Self-hosted fonts with next/font

How next/font self-hosts your typography at build time, killing the third-party request, the privacy leak, and the layout shift that a plain Google Fonts link ships, then wires each face into Tailwind as a theme token.

You need a brand font on your SaaS. You search how to add one, and every tutorial gives you the same answer: drop two <link> tags into the <head>.

<head>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter&display=swap" rel="stylesheet" />
</head>

It works, and the font shows up. But it quietly hands you three problems, and you won’t notice any of them until they cost you.

  1. It blocks rendering on a server you don’t own. That stylesheet <link> is a render-blocking request to a third-party origin. Before the browser can paint your text, it has to reach fonts.googleapis.com, parse the CSS, then fetch the actual font file from fonts.gstatic.com. While that round-trip is in flight, the browser either hides the text entirely or paints a fallback that jumps to the real font the moment it arrives.

  2. Every visitor’s browser phones Google. On every page load, each visitor’s browser connects directly to Google’s servers, handing over their IP address and the page they’re on. This isn’t hypothetical: a German court ruled that embedding Google Fonts this way violates the GDPR , precisely because it leaks visitor IPs to a third party without consent. For a SaaS, that’s a real compliance finding, not a footnote.

  3. Your typography now depends on a CDN you don’t control. If fonts.gstatic.com is slow, blocked by a corporate firewall, or unreachable from some region, your carefully chosen brand face silently degrades to a system font. You’ll never see it happen, because it works fine on your machine.

That’s three failures from two lines of HTML. It’s the same shape you saw earlier with images in Images with next/image: a plain platform tag looks harmless, but it silently regresses the things a careful engineer would never let slip. The fix is the same kind too. A platform primitive takes the discipline you’d otherwise apply by hand and bakes it into the build. Here that primitive is next/font.

In one sentence, here is what it does: next/font downloads the font at build time and serves it from your own origin, with a pre-computed fallback metric so the text never jumps. No third-party request at runtime, no IP leak, no layout shift.

You may not expect the next part: you already have this. When you scaffolded your project with create-next-app, it wired up Geist through next/font in your root layout, so you’ve been shipping self-hosted fonts the whole time without knowing it. This lesson explains the code you already have, then teaches you to extend it.

One quick refresher first. In the images lesson you met CLS , the Core Web Vital that measures how much content jumps around as the page loads. A font swapping from a wrong-width fallback to the real font is a common CLS source: the text reflows the instant the real font lands. The fallback-metric mechanism at the heart of next/font exists to remove exactly that jump, so keep CLS in the back of your mind.

We’ll go in this order: first what the pipeline actually does, then loading a Google font, then a local brand face, then the Tailwind bridge that makes all of it usable, and finally the loading discipline that keeps it correct.

What next/font actually does: the build-time pipeline

Section titled “What next/font actually does: the build-time pipeline”

Before any of the API, build the mental model. next/font is a build-time pipeline. Everything you’ll configure later is a knob on this pipeline, so once you see the shape, the options read as obvious rather than arbitrary.

It does four things, and each one removes a failure from that opening list.

It downloads the font at build. During next build, the loader fetches the Google font files (or reads your local ones) and emits them as static assets under your own domain. After that, there is no runtime request to Google: your visitors only ever talk to your origin. That single move removes both the privacy leak and the CDN-dependency failure at once.

It generates the @font-face for you. You never hand-write a @font-face rule. The loader emits one pointing at the self-hosted files, with the right font-display and source paths already filled in.

It computes a fallback-font metric. This is the step worth the most attention, because it does the work that prevents the layout shift. Think about what happens while a web font loads: the browser needs to show something, so it paints a fallback font instantly, say Arial. When the real font finally arrives, it usually has slightly different letter widths and line heights, so the text reflows to fit, and that reflow is the jump. next/font heads it off by measuring the real font’s metrics at build time and synthesizing an adjusted fallback: an Arial tuned with CSS properties like size-adjust and ascent-override so it occupies almost exactly the same space the real font will. When the swap finally happens, nothing moves, because the fallback was already the right size. This adjustment is called adjustFontFallback, it’s on by default, and it is the single feature responsible for eliminating font-driven CLS.

It wires up preloading. It adds the <link rel="preload"> for the font files so the browser starts fetching them early. You don’t manage that tag; the loader does.

Here’s the whole sequence laid out concretely.

Your project next build
✓ fires once fetch files + metrics
Google Fonts contacted once, at build
emit
Self-hosted assets /_next/static/…
At build time the font is fetched once and baked into your own origin. This is the only moment Google is ever contacted.
Acme
adjusted fallback face
only request
Your origin serves the font
Google Fonts never contacted
At runtime the visitor's browser only ever talks to your domain. Text paints instantly in a metric-matched fallback — no third-party request, no IP leak.
Acme
✓ real font, no shift
✓ no layout shift
When the real font arrives, the swap is invisible — the fallback was pre-sized to match, so nothing reflows.

The swap behavior in the last step is two things working together. The font shows a fallback first and then swaps to the real face, rather than hiding the text until it loads, and the computed fallback metric makes that swap shift-free.

These two behaviors have names you’ll see in font discussions everywhere: FOUT and FOIT . FOIT is the worse failure, because blank text is invisible content. The pipeline’s defaults give you the controlled, shift-free version of FOUT and avoid FOIT: you see text immediately, and the swap doesn’t move anything.

Loading a Google font: subsets and the variable-font default

Section titled “Loading a Google font: subsets and the variable-font default”

Here’s the canonical call, applied to your root layout. It’s the Geist code you’ll half-recognize from your scaffold.

app/layout.tsx
import { Geist } from 'next/font/google';
const geist = Geist({ subsets: ['latin'] });
export const metadata = { /* ... */ };
const RootLayout = ({ children }: { children: ReactNode }) => (
<html lang="en" className={geist.className}>
<body>{children}</body>
</html>
);
export default RootLayout;

Three pieces are doing the work here.

The font is called at module scope, once. Geist({...}) runs at the top of the file, not inside the component. That detail matters more than it looks, and getting it wrong has a real failure mode that we’ll cover properly later. For now, just notice where the call lives.

It returns an object. A next/font call hands you back an object with three useful fields: className, style, and variable. Here we use className: apply it to <html> and, because font-family inherits, every element on the page picks up the font.

subsets: ['latin'] is something you always declare. A full Google font ships glyphs for every script it supports: Cyrillic, Greek, Vietnamese, and more. You will never render most of those characters. subsets slices the font down to just the script you serve, which is often a fraction of the bytes, and it tells the platform which slice to preload. If you omit subsets while preloading is on (and preloading is on by default), the build emits a warning. It’s not a hard error, and the build still completes, but the warning is the platform telling you that you’re about to ship a font that is neither subset nor preloaded. So always declare every subset you actually render. For most SaaS apps that’s ['latin']; add ['latin', 'latin-ext'] if you display accented European text like Łódź or Köln.

Variable fonts ship every weight in one file

Section titled “Variable fonts ship every weight in one file”

There’s really only one decision to make when you load a font: variable or static.

A variable font packs the entire weight axis into a single file, from thin at 100 all the way to black at 900 and everything between. A static font is one file per weight: one file for regular, another for bold, another for each weight you want. Counterintuitively, a single variable font is often smaller than two static weights, and it gives you every weight in between for free. So the default is straightforward: use the variable font whenever the family has one. Geist, Inter, and Roboto Flex all do. Reach for static fonts with explicit weights only when a family ships no variable version.

That decision changes one thing in the API: whether you pass weight.

app/layout.tsx
const geist = Geist({ subsets: ['latin'] });

Omit weight entirely. The variable font carries every weight in one file, so Tailwind’s font-bold, font-medium, and the rest all resolve from it. This is the pick whenever the family is variable, which is most of the time.

Italics follow the same rule. For a static font, pass style: ['normal', 'italic'] only if you actually render italic text; a variable font handles slanting without you listing it. The discipline across both weight and style is the same: declare exactly what you render, and nothing more. Every extra weight or style is another file the browser downloads for no reason.

Sometimes the font you want isn’t on Google Fonts at all. A design system often ships its own custom display face, a .woff2 file living in your repo, and you want that file to get the identical treatment: self-hosted, preloaded, zero CLS. That’s what next/font/local is for. It’s the same pipeline with a different source.

app/layout.tsx
import localFont from 'next/font/local';
const acmeDisplay = localFont({
src: './fonts/acme-display.woff2',
variable: '--font-display',
});

A few things are worth knowing here.

src is relative to the file that calls localFont, not to the project root. So './fonts/acme-display.woff2' resolves next to app/layout.tsx. Co-locate the font with the layout, or keep all your faces in something like app/fonts/. The file lives in your repo, and the build self-hosts it exactly as it does a Google font.

If a family ships across multiple files, separate files for regular, bold, and italic, say, then src takes an array instead, one entry per file.

Multi-file family
app/layout.tsx
const acmeDisplay = localFont({
src: [
{ path: './fonts/acme-display-regular.woff2', weight: '400', style: 'normal' },
{ path: './fonts/acme-display-bold.woff2', weight: '700', style: 'normal' },
],
variable: '--font-display',
});

Each entry maps a file to its weight and style, so the browser loads the right file for each.

Ship .woff2 and nothing else. It’s the smallest format with full modern-browser support, so shipping a .ttf, .otf, or older .woff alongside it is strictly more bytes for zero benefit. One .woff2 per face is the whole story.

One detail to flag before the next section: notice this call sets variable, not className. That’s deliberate. This brand face is going to become a named Tailwind token, something you can reach for with a utility class, and that’s exactly what the next section sets up.

The Tailwind bridge: a font is just another theme token

Section titled “The Tailwind bridge: a font is just another theme token”

This is the section to slow down on. Loading a font is easy. The part people get wrong, including AI assistants that confidently write the legacy shape, is wiring it into Tailwind so that the font-sans and font-display utilities actually resolve to your self-hosted fonts. Get this right and a font becomes a first-class part of your design system. Get it wrong and you’ve loaded a font the rest of your styling can’t reach.

One framing makes it click. Back in the design tokens chapter you learned that every design token in this stack, every color and every spacing step, flows through @theme in globals.css. A font family is no different. It’s a design token whose value happens to come from a next/font call instead of an OKLCH literal. The whole trick is that the font call and your Tailwind theme meet at a single CSS custom property.

The bridge is three hops, and a bug here is always one of them broken, so look at all three at once.

1

next/font call

acmeDisplay.variable
→  --font-display : '__acmeDisplay_abc'
variable born

The .variable className carries the custom property.

2

<html> element

<html className={
  acmeDisplay.variable }>
live on the root

--font-display is now set on the document and inherits everywhere.

3

@theme in globals.css

@theme inline {
   --font-display : var( --font-display );
}
✓ mints font-display

Tailwind aliases the variable into a theme token and generates the font-display class.

The font call and your Tailwind theme meet at one CSS variable. Set it on <html>, alias it in @theme, then use font-display anywhere.

Here’s the actual code. This is the setup you’ll keep: three faces wired all the way through, the Geist sans body font, Geist Mono for code, and the Acme brand display face. It lives in two files that must agree with each other, so look at them side by side.

app/layout.tsx
import { Geist, Geist_Mono } from 'next/font/google';
import localFont from 'next/font/local';
const geistSans = Geist({ subsets: ['latin'], variable: '--font-sans' });
const geistMono = Geist_Mono({ subsets: ['latin'], variable: '--font-mono' });
const acmeDisplay = localFont({
src: './fonts/acme-display.woff2',
variable: '--font-display',
});
const RootLayout = ({ children }: { children: ReactNode }) => (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} ${acmeDisplay.variable}`}
>
<body>{children}</body>
</html>
);
export default RootLayout;

Each font exposes its own --font-* variable. Concatenate the three .variable classNames onto <html> so all three custom properties go live on the root and inherit everywhere. You’re still setting className on the element, but with the variable className, which sets a CSS variable, rather than the bare className, which would set font-family directly. The two are easy to confuse, and for a Tailwind app you always want .variable.

One correction is worth stating plainly: there is no tailwind.config.ts in this course. Older tutorials, and the entire Tailwind v3 era, told you to register fonts in a JavaScript config file, with something like fontFamily: { sans: ['var(--font-sans)'] }. That is the legacy shape, and it is wrong for a v4 project. Tailwind v4 is CSS-first: the font token lives in @theme in globals.css, right alongside your colors and spacing, exactly as you learned in the tokens chapter. If you ever open a project and find a tailwind.config.ts with a fontFamily array, you’re looking at v3; the v4 equivalent is the @theme mapping above.

Let’s lock the bridge in with a quick drill.

Wire the brand face so that the font-display utility works. Pick the @theme value that aliases the font variable, then the utility class that lands the face on the heading. Pick the right option from each dropdown, then press Check.

app/globals.css
@import "tailwindcss";
@theme inline {
--font-display: ___;
}
app/(marketing)/page.tsx
<h1 className="___">Acme</h1>

So why variable plus @theme, instead of the bare className route you saw with the very first Geist example? The rule to carry is this: in a Tailwind app, always wire fonts through variable and the @theme bridge, never the bare className. The bare className hard-codes one font directly onto an element, and no utility class can reference it. That’s fine for a single global font, but useless the moment you have a sans, a mono, and a display face you need to switch between. The variable route makes each font a named token your utilities can reach, which is the whole reason the bridge exists.

Where each font loads, and why module scope matters

Section titled “Where each font loads, and why module scope matters”

Two disciplines live here, and they sound similar but aren’t. One is about which layout you load a font in. The other is about where in the file you call the loader. Both come down to not paying for work you don’t need.

Load the body font once, the marketing face only where it’s seen

Section titled “Load the body font once, the marketing face only where it’s seen”

Every font family is bytes the browser has to download, so where you load a font should match where it’s actually rendered.

Your default body font belongs in the root layout: it’s on every route, so it loads once at the root, and that’s correct. But a font that only appears on some routes shouldn’t load on all of them. If your flashy acme-display face only shows up on marketing pages, load it in app/(marketing)/layout.tsx, not the root. Then your app dashboard routes, which never render that face, never download it. You already know route groups and nested layouts from the routing chapter; here you’re just using them to scope a cost. The reflex is to never load four font families on every route, and to keep a font’s loading scope matched to where it renders.

Declare the font at module scope, never inside a component

Section titled “Declare the font at module scope, never inside a component”

This is the mistake that shows up most in real code, and one an AI assistant will happily ship for you, so it’s worth getting precise about.

The rule is that the Geist({...}) or localFont({...}) call must live at module top level, where it runs once when the module first loads. Put it inside a component body and it runs again on every single render.

To see why that’s a problem, lean on the React render model from the rendering chapter: a component function re-runs every time it renders, and a component re-renders constantly. The font loader is meant to run exactly once, at build and module-load time, which is the whole optimization. Call it inside a component and you re-run the loader machinery on every render. That throws the build-time work away, and for a local font it re-reads the source each time. Nothing that’s supposed to happen once belongs in a render body.

import { Inter } from 'next/font/google';
const Page = () => {
const inter = Inter({ subsets: ['latin'] });
return <h1 className={inter.className}>Acme</h1>;
};

Re-initializes the font on every render. The loader is meant to run once at build and module-load; calling it inside the component throws that away and can warn or misbehave.

Two related reflexes follow from this. First, don’t load a font in a Client Component. Fonts are a server and build-time concern; calling a loader inside a 'use client' file forfeits the server-side optimization, so keep the next/font call in your server-rendered layout. Second, don’t try to pass the font object across the server/client boundary. The only things you ever hand around are its className, variable, and style fields, never the object itself.

There’s a natural next step to the module-scope rule. Once more than one file needs the same font, say your root layout and a marketing layout both want Geist, you don’t want to call the loader twice. The idiomatic shape, straight from the docs, is to call every loader once in a single module and import the results everywhere.

app/fonts.ts
import { Geist, Geist_Mono } from 'next/font/google';
import localFont from 'next/font/local';
export const geistSans = Geist({ subsets: ['latin'], variable: '--font-sans' });
export const geistMono = Geist_Mono({ subsets: ['latin'], variable: '--font-mono' });
export const acmeDisplay = localFont({
src: './fonts/acme-display.woff2',
variable: '--font-display',
});

Now any layout just does import { geistSans } from '@/app/fonts'. This is module-scope discipline taken one step further: one instance per font for the entire app, imported wherever it’s needed, never re-called.

A short list of edges, so you reach for the right tool when you hit one.

  • No runtime CDN fetches. Everything happens at build time. There’s no mode that fetches a font from Google on demand at request time, which is the whole point of the primitive.
  • No silent guessing of subsets. For Google fonts the platform warns you rather than picking a subset on your behalf. That’s deliberate too: it’s the same always-declare-subsets habit, enforced as a boundary.
  • Not for icon fonts. Icon fonts, the FontAwesome-style approach of shipping a whole font where each glyph is an icon, are an anti-pattern in 2026. They download an entire font for a handful of symbols and break accessibility. Use SVG icons instead: that’s what the Lucide components from the icons chapter are for. Don’t reach for next/font to load an icon font.

One forward pointer: this lesson is only the font-family wiring. The broader typography story, covering the type scale, line height, and the prose reading surface, was the Tailwind typography chapter. Here we’ve done exactly one thing: gotten your fonts self-hosted and reachable as tokens.

One frame to carry out of this lesson: next/font turns a render-blocking, privacy-leaking <link> into a self-hosted, zero-CLS, build-time asset, and the variable bridge turns each font into a Tailwind token you reference like any other.

And the review reflexes, the things to check whenever you wire a font:

  1. Declare subsets on every Google font. The platform warns you otherwise, and it’s where the byte savings and the preload live.
  2. Variable fonts omit weight; static fonts list only what they render. Prefer variable.
  3. Wire fonts through variable and @theme in globals.css, never a tailwind.config.ts. A font is a design token, like a color.
  4. Call the loader at module scope, once, never inside a component.
  5. next/font needs zero next.config.ts. Unlike images and redirects, which earned their config entries, the entire font pipeline is just the import. There’s no configuration to write at all.