
Next.js Font Optimization
Web fonts have a reputation for quietly wrecking performance scores. You add a nice-looking Google Font to your project, ship it, and a week later someone points out that your Largest Contentful Paint got worse and the text jumps around for a split second every time the page loads. That jump has a name — Cumulative Layout Shift — and for years the standard advice was a pile of manual workarounds: preconnect hints, self-hosting scripts, font-display: optional tuning, and a fair amount of guesswork.
Next.js folds all of that into a single module, next/font, and handles it automatically. It self-hosts any font you give it, whether that's a Google Font or a font file sitting in your own project, strips out the network request to Google's CDN entirely, and calculates a fallback font's metrics so the browser can reserve the right amount of space before your custom font has even downloaded. This article walks through how it works, how to use it with both Google Fonts and local font files, and the practical details — subsetting, variable fonts, multiple fonts, Tailwind integration — that the getting-started page glosses over.
Why Fonts Are a Performance Problem in the First Place
Before touching the API, it's worth understanding what problem you're actually solving, because the answer changes how you configure things later.
When a browser loads a page that references an external font — say, a <link> tag pointing at fonts.googleapis.com — it has to make a separate network round trip to fetch that font's CSS, and then another round trip (or several, for weights and styles) to fetch the actual font files. Every one of those round trips is on the critical path to displaying styled text. Slow connections make this worse, but even on fast connections you're adding two extra DNS lookups, two extra TLS handshakes, and often a redirect, all before a single glyph renders.
There's a second, subtler problem: layout shift. Browsers render text with a fallback font (usually a system font) immediately, then swap to your custom font once it loads. If your custom font has different letter widths and line heights than the fallback, every element sized around that text jumps or reflows when the swap happens. This is Cumulative Layout Shift, one of Google's Core Web Vitals, and it directly affects both user experience and search ranking.
next/font addresses both problems at once. It downloads Google Fonts at build time instead of at request time, serves the resulting files from your own domain, and — this is the clever part — calculates size-adjusted metrics for a fallback font so the fallback occupies almost exactly the same space as your real font. By the time the real font swaps in, there's little to nothing to shift.
The Basic Pattern
Everything in next/font follows the same shape: you import a font-loading function, call it with some configuration, and it returns an object with a className (and a few other properties) that you attach to an element.
// app/layout.tsx
import { Geist } from "next/font/google";
const geist = Geist({
subsets: ["latin"],
});
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className={geist.className}>
<body>{children}</body>
</html>
);
}
That's the whole integration for a single global font. Geist here is a named export from next/font/google — it's not a component, it's a function that, when called, triggers Next.js's build tooling to fetch the font, generate optimized @font-face declarations, and return a stable class name pointing at them.
Two details matter immediately:
Fonts are scoped to wherever you apply the className. If you call Geist() in layout.tsx and put the resulting class on <html>, the font cascades down to your entire app because CSS font-family inherits. If you instead call it inside a single component and apply the class there, only that component and its children get the font. This is genuinely useful — you're not stuck with one font for the whole site unless you want to be.
The function call itself does the heavy lifting. You're not importing a CSS file or a <link> tag. Next.js's build process intercepts the call to Geist(...), downloads the font files matching your options, generates a scoped CSS class with the right @font-face rules, and self-hosts the actual font binary as a static asset alongside your build output. None of this happens at runtime in the browser — it's all resolved during next build (or on first request in dev).
Using Google Fonts
Import any Google Font by name from next/font/google. The names map directly to Google's font catalog, with one quirk: fonts with multi-word names use an underscore instead of a space.
import { Roboto_Mono } from "next/font/google";
const robotoMono = Roboto_Mono({
subsets: ["latin"],
});
Roboto Mono becomes Roboto_Mono. This trips people up constantly, especially with fonts like Source Sans 3 or IBM Plex Sans, which become Source_Sans_3 and IBM_Plex_Sans. If TypeScript or your editor's autocomplete can't find the font you're typing, check for missing underscores before you assume the font isn't supported.
Variable Fonts vs. Fixed-Weight Fonts
Next.js strongly recommends variable fonts, and once you understand why, you'll want to reach for them by default. A variable font is a single file that encodes a continuous range of weights (and sometimes widths, slants, and other axes) rather than shipping a separate file per weight. One file, every weight from 100 to 900, versus nine separate files for a traditional font family.
With a variable font, you often don't need to specify a weight at all:
import { Geist } from "next/font/google";
const geist = Geist({
subsets: ["latin"],
});
If the font you want isn't variable, or you specifically need a fixed weight, you must declare it — omitting weight on a non-variable font throws a build error, not a silent fallback:
import { Roboto } from "next/font/google";
const roboto = Roboto({
weight: "400",
subsets: ["latin"],
});
You can also request multiple weights and styles as arrays, which generates multiple @font-face declarations under the hood:
const roboto = Roboto({
weight: ["400", "700"],
style: ["normal", "italic"],
subsets: ["latin"],
display: "swap",
});
Be deliberate about this. Every weight and style combination you request is a separate file the browser has to download somewhere in your app. Requesting four weights across two styles when you only ever use two of those combinations in your actual UI is pure waste — check your design system before you list every weight "just in case."
Subsets: The Option You Cannot Skip
subsets is technically marked optional in the type signature, but in practice you should treat it as required. Google Fonts ship with dozens of language subsets (Latin, Cyrillic, Greek, Vietnamese, and so on), and downloading all of them by default would bloat your bundle enormously. If you don't specify which subsets to preload while preload is true (the default), Next.js prints a build warning and preloading is effectively disabled for that font — you lose the performance benefit silently.
const inter = Inter({ subsets: ["latin"] });
For most English-language and Western European sites, ['latin'] is all you need. If your site serves other scripts, check the specific font's page on Google Fonts to see which subset names it supports — they're not always intuitively named (latin-ext is a real, separate subset from latin, for instance).
Using Local Fonts
If you're working with a licensed font, a custom brand typeface, or anything not on Google Fonts, next/font/local gives you the same self-hosting and metric-optimization benefits for files you supply yourself.
import localFont from "next/font/local";
const myFont = localFont({
src: "./my-font.woff2",
});
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className={myFont.className}>
<body>{children}</body>
</html>
);
}
The src path is resolved relative to the file where localFont is called, not relative to your project root. This matters more than it sounds like it should — if you call localFont from app/layout.tsx and your font sits in app/fonts/my-font.woff2, the path is ./fonts/my-font.woff2. Move the call to a different file and every relative path in it needs to move with it, or you get a build-time "file not found" error that doesn't always point clearly at the actual mistake.
Font files can live anywhere in your project — co-located inside app/, tucked into a dedicated fonts directory, or placed in public/ if you'd rather serve them as plain static assets and reference them that way. Co-locating with the component or layout that uses them is the more common pattern, since it keeps the font physically next to the code that depends on it.
For a font family that ships as multiple separate weight/style files (common with commercial typefaces that don't provide a variable version), pass an array to src:
const roboto = localFont({
src: [
{ path: "./Roboto-Regular.woff2", weight: "400", style: "normal" },
{ path: "./Roboto-Italic.woff2", weight: "400", style: "italic" },
{ path: "./Roboto-Bold.woff2", weight: "700", style: "normal" },
{ path: "./Roboto-BoldItalic.woff2", weight: "700", style: "italic" },
],
});
Each entry becomes its own @font-face rule, and the browser picks the right file based on the font-weight and font-style you apply in CSS. This is functionally identical to how a fixed-weight Google Font with multiple weights behaves — the API is intentionally symmetric between next/font/google and next/font/local.
The Options You'll Actually Reach For
Beyond src, weight, and subsets, a handful of other options are worth knowing well before you need them mid-debugging-session.
display controls the CSS font-display behavior — how the browser handles the gap between showing fallback text and swapping in the real font. It defaults to 'swap', which shows fallback text immediately and swaps as soon as the real font loads. Other values are 'auto', 'block', 'fallback', and 'optional'. 'optional' is worth considering for non-critical decorative fonts: it gives the browser permission to skip the swap entirely if the font doesn't arrive within a very short window, which guarantees zero layout shift at the cost of occasionally not using your custom font on a slow connection.
preload defaults to true and injects a <link rel="preload"> tag for the font, telling the browser to fetch it with high priority before it's even referenced by CSS. Preloading is scoped intelligently: a font loaded in a specific page.tsx preloads only on that route, a font loaded in a layout preloads on every route under that layout, and a font loaded in the root layout preloads globally. You rarely need to touch this option, but if you're loading a font that's only used far down a rarely-visited page, setting preload: false avoids wasting bandwidth on visitors who will never see it.
fallback lets you specify which system fonts the browser should use while your custom font loads (or if it fails to load at all):
const inter = Inter({
subsets: ["latin"],
fallback: ["system-ui", "arial"],
});
adjustFontFallback is the option doing the real layout-shift-prevention work, and it's enabled by default, which is why most people never think about it. Next.js measures your chosen font's metrics (ascent, descent, line gap, and average character width) and generates an adjusted, invisible fallback @font-face that matches those metrics almost exactly. The fallback font renders with the same footprint as your real font, so when the swap happens, nothing moves. For next/font/google it's a boolean; for next/font/local it also accepts specific fallback base font names like 'Arial' or 'Times New Roman', or false to disable it.
variable switches you from the className approach to a CSS-variable approach, which is essential once you're juggling more than one font (more on that below).
Applying the Font: Three Approaches
next/font gives you three ways to actually apply the generated styles, and choosing the right one depends on how much control you need.
className is the simplest and what you'll use 90% of the time:
<p className={inter.className}>Hello, Next.js!</p>
style returns a plain object with fontFamily (and fallback fonts baked in) if you need to apply styles inline or merge them into an existing style object:
<p style={inter.style}>Hello World</p>
CSS variables are the right choice when you want to reference the font from an external stylesheet, CSS Module, or Tailwind config rather than only via the generated className:
const inter = Inter({
subsets: ["latin"],
variable: "--font-inter",
});
<main className={inter.variable}>
<p className={styles.text}>Hello World</p>
</main>
/* styles/component.module.css */
.text {
font-family: var(--font-inter);
font-weight: 200;
font-style: italic;
}
Note that setting variable alone doesn't apply the font anywhere — it just makes the CSS custom property available on whatever element you put the variable class on. You still need a font-family: var(--font-inter) rule somewhere to actually use it. This is the detail that trips people up the most: they set variable, see no visual change, and assume the font isn't loading, when really they just never referenced the variable in CSS.
Using Multiple Fonts Without Making a Mess
Real projects usually want more than one font — a sans-serif for body text and a monospace for code blocks, say. There are two clean ways to structure this, and the CSS-variable approach scales better as the number of fonts grows.
Option one: a utility file with per-font exports, applying className where each font is actually used:
// app/fonts.ts
import { Inter, Roboto_Mono } from "next/font/google";
export const inter = Inter({
subsets: ["latin"],
display: "swap",
});
export const robotoMono = Roboto_Mono({
subsets: ["latin"],
display: "swap",
});
// app/layout.tsx
import { inter } from "./fonts";
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={inter.className}>
<body>{children}</body>
</html>
);
}
// app/page.tsx
import { robotoMono } from "./fonts";
export default function Page() {
return <h1 className={robotoMono.className}>My page</h1>;
}
Option two: CSS variables set once at the root, referenced by selector in CSS. This is the pattern I'd reach for by default, because it means the mapping between "font" and "where it's used" lives entirely in your stylesheet, not scattered across component files:
// app/layout.tsx
import { Inter, Roboto_Mono } from "next/font/google";
const inter = Inter({
subsets: ["latin"],
variable: "--font-inter",
display: "swap",
});
const robotoMono = Roboto_Mono({
subsets: ["latin"],
variable: "--font-roboto-mono",
display: "swap",
});
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className={`${inter.variable} ${robotoMono.variable}`}>
<body>{children}</body>
</html>
);
}
/* app/global.css */
html {
font-family: var(--font-inter);
}
h1 {
font-family: var(--font-roboto-mono);
}
Whichever pattern you choose, keep the total font count low. Next.js's own docs put it plainly: use multiple fonts conservatively, since every additional font is another network resource the client has to fetch, even self-hosted. Two, maybe three font families covers the vast majority of real design systems. If you find yourself loading five or six, that's usually a design decision worth revisiting, not a technical problem to optimize your way out of.
Fonts with Tailwind CSS
If your project uses Tailwind (as most new Next.js projects do), next/font's CSS-variable output maps directly onto Tailwind's theme configuration, which is by far the cleanest integration.
// app/layout.tsx
import { Inter, Roboto_Mono } from "next/font/google";
const inter = Inter({
subsets: ["latin"],
display: "swap",
variable: "--font-inter",
});
const robotoMono = Roboto_Mono({
subsets: ["latin"],
display: "swap",
variable: "--font-roboto-mono",
});
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html
lang="en"
className={`${inter.variable} ${robotoMono.variable} antialiased`}
>
<body>{children}</body>
</html>
);
}
/* global.css */
@import "tailwindcss";
@theme inline {
--font-sans: var(--font-inter);
--font-mono: var(--font-roboto-mono);
}
With Tailwind v4's CSS-first @theme configuration, that's the entire setup — the font-sans and font-mono utility classes now resolve to your self-hosted fonts everywhere in your markup. If you're still on Tailwind v3 with a JS config file, the equivalent is extending theme.fontFamily in tailwind.config.js with the same CSS variables, which then unlocks the same utility classes.
Font Definitions Files: Solving the "Same Font, Many Places" Problem
Here's a subtlety that isn't obvious until you hit it: every time you call a font-loading function like Inter(...) or localFont(...), Next.js treats that as a distinct font instance in your application. If you call Inter({ subsets: ['latin'] }) in three different files, you don't get one shared font — you risk generating redundant loading logic for what should be a single, shared resource.
The fix is to centralize font declarations in one file and import the resulting objects wherever they're needed, rather than re-invoking the font function all over your codebase:
// styles/fonts.ts
import { Inter, Lora, Source_Sans_3 } from "next/font/google";
import localFont from "next/font/local";
const inter = Inter();
const lora = Lora();
const sourceSansPro400 = Source_Sans_3({ weight: "400" });
const sourceSansPro700 = Source_Sans_3({ weight: "700" });
const greatVibes = localFont({ src: "./GreatVibes-Regular.ttf" });
export { inter, lora, sourceSansPro400, sourceSansPro700, greatVibes };
// app/page.tsx
import { inter, lora, sourceSansPro700, greatVibes } from "../styles/fonts";
export default function Page() {
return (
<div>
<p className={inter.className}>Hello world using Inter font</p>
<p style={lora.style}>Hello world using Lora font</p>
<p className={sourceSansPro700.className}>
Hello world using Source Sans 3 at weight 700
</p>
<p className={greatVibes.className}>My title in Great Vibes font</p>
</div>
);
}
This is the same principle you'd apply to any expensive, shared resource in a codebase — define it once, import the reference everywhere. A path alias makes this even more convenient:
// tsconfig.json
{
"compilerOptions": {
"paths": {
"@/fonts": ["./styles/fonts"]
}
}
}
import { greatVibes, sourceSansPro400 } from "@/fonts";
For any project with more than two or three fonts spread across multiple components, set this pattern up early. Retrofitting it after font calls are scattered across twenty files is a tedious refactor you can skip entirely by starting this way.
Common Mistakes and Gotchas
Forgetting the underscore in multi-word font names. Roboto Mono is Roboto_Mono, Source Sans 3 is Source_Sans_3. If the import fails to resolve, this is the first thing to check.
Calling a font-loading function inside a component that re-renders often. Font functions are meant to be called once, at module scope, not inside a component body on every render. All the examples in this article call them at the top level of a file for a reason — treat this like you would any other expensive setup that belongs outside your render path.
Skipping subsets and wondering why preloading silently doesn't happen. As covered above, this produces a build warning, not an error, so it's easy to miss.
Assuming variable alone applies a font. It only exposes a CSS custom property — you still need a font-family rule that references it.
Loading fonts in a way that isn't scoped to where they're used. If a font is only used on one rarely-visited route, don't put its font-loading call in the root layout. Put it in that route's own page.tsx or a layout scoped to that section, so it only preloads where it's needed.
Not checking whether a font is variable before specifying weight. If you specify a weight for a variable font, it still works, but you're often overriding a sensible default and adding unnecessary constraints. Check the font's page on Google Fonts — if it's listed as supporting a weight range like 100 900, treat it as variable and skip fixed weights unless you have a specific reason not to.
When Not to Reach for next/font
There genuinely isn't much of a case against using next/font for standard web fonts in a Next.js App Router project — it's strictly better than manual <link> tags or CSS @import for Google Fonts in almost every dimension: privacy (no request to Google), performance (self-hosted, build-time resolved, layout-shift-adjusted), and simplicity. The one scenario where you'd skip it is if you're loading fonts dynamically based on runtime data you don't have at build time — for instance, letting end users of a multi-tenant SaaS app upload and select their own custom font per-tenant. next/font's optimizations are fundamentally build-time and static-analysis-based; it needs to see the font-loading call at build time to do its work, so purely dynamic, user-driven font selection falls outside what it's designed for.
Key Takeaways
| Scenario | What to use |
|---|---|
| Any Google Font | next/font/google, always specify subsets |
| Custom/licensed font file | next/font/local, path relative to the calling file |
| Font used app-wide | Apply className (or variable) in the root layout |
| Font used in one route/section | Apply className in that route's own layout or page |
| Multiple fonts | CSS variables (variable option) referenced by selector in your stylesheet |
| Tailwind CSS integration | variable option + @theme inline mapping to --font-sans / --font-mono |
| Reducing layout shift further | Leave adjustFontFallback enabled (default); consider display: 'optional' for non-critical fonts |
| Same font needed in many files | Centralize in a font definitions file, import the object, don't re-call the loader |
next/font is one of those Next.js features that quietly removes an entire category of performance work you'd otherwise have to do by hand — measuring fallback metrics, wiring up preconnect and preload tags, deciding on subsetting strategy. Once you understand the handful of options above, most of what's left is a design decision (how many fonts, which weights) rather than a technical one.


