Font optimization (next/font) in Next.js
next/font is Next.js's built-in font system that self-hosts fonts at build time, removes external network requests, and eliminates layout shift (CLS) through automatic size-adjust.
Theory
TL;DR
next/fontdownloads fonts at build time and serves them from your domain, no Google servers involved at runtime- Traditional
<link href="fonts.googleapis.com">blocks rendering and causes CLS;next/fontfixes both classNameapplies a font-family stack directly to an element;variablecreates a CSS custom property like--font-interfor use in Tailwind or globals.cssdisplay: 'swap'shows the fallback font immediately, then swaps when the custom font loads- Use
next/fontfor any font in a production Next.js app; skip only for non-JS static sites where CLS is not a concern
Quick example
// app/layout.tsx
import { Inter } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
display: 'swap',
variable: '--font-inter',
});
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={inter.variable}>
<body className={inter.className}>
{children}
</body>
</html>
);
}
// At build: WOFF2 downloaded, @font-face inlined in CSS,
// <link rel="preload" as="font"> added to <head> automaticallyhtml gets the CSS variable, body gets the font-family class. Both are needed when you want Tailwind integration alongside direct application.
Traditional Google Fonts vs next/font
The old approach adds <link href="https://fonts.googleapis.com/css2?family=Inter"> to <head>. That tag fires a DNS lookup, downloads a CSS file, then downloads the font files. During those network trips the browser renders text in the fallback font at a different size. When the custom font finally arrives, the text jumps. That jump is CLS.
next/font cuts all of that. Fonts land in your build output as static WOFF2 files. The @font-face rule is written into your CSS at build time. And size-adjust is computed from the font's actual vertical metrics (ascent, descent, line-gap ratios) so the fallback font matches the custom font's dimensions closely enough that no visible jump occurs.
When to use
- New Google Font needed: import from
next/font/google, done, no other config required - Custom or paid font in WOFF2 format: use
next/font/localwith a path relative to the file where you define it - Multiple fonts (heading + body + mono): define each separately, apply CSS variables, connect them in your Tailwind config
- Variable font with a weight range: add
axes: ['wght']so a single WOFF2 file handles every weight - Already using
<link>tags: migrate tonext/fontbefore going to production; CLS directly affects Core Web Vitals scores
How it works at build time
During next build, Next.js reads your font configs, fetches files from Google (or uses your local files), converts to WOFF2 if needed, and writes them into the build output. It generates an @font-face rule with a size-adjust value computed from the font's vertical metrics. That rule goes into global CSS. A <link rel="preload" as="font"> tag is injected into <head> so the browser fetches the font early in the waterfall.
At runtime there are zero external requests. Fonts are cached like any other static asset with long-lived cache headers. One thing worth knowing: in next dev this optimization is skipped and fonts load externally. You won't see the full benefit until next build.
Key options reference
| Option | What it does | Example |
|---|---|---|
subsets | Character sets to include in the download | ['latin', 'cyrillic'] |
weight | Specific weights to load | ['400', '700'] |
display | CSS font-display value | 'swap' (recommended) |
variable | Name for the CSS custom property | '--font-inter' |
preload | Whether to add <link rel="preload"> | true (default) |
adjustFontFallback | Override the fallback font for size-adjust calc | 'Arial' |
axes | Enable variable font axes | ['wght'] |
Common mistakes
1. Wrong subset for non-Latin text
// Wrong: only latin glyph data downloaded at build time
const inter = Inter({ subsets: ['latin'] });
// <p>Привет</p> renders as squares or invisible textFix: subsets: ['latin', 'cyrillic']. The subset list defines what glyph data gets bundled. Missing glyphs cannot be synthesized at runtime.
2. display: 'block' on body text
// Wrong: text invisible until font loads, up to 3 seconds
const inter = Inter({ display: 'block' });block tells the browser to show nothing until the font arrives. For body text on a slow connection that means several seconds of blank content. Use 'swap' (the default) for body fonts. Reserve 'block' for icon fonts where showing a fallback character would be worse than showing nothing.
3. Applying className to <html> instead of <body>
// Wrong: font-family on html doesn't cascade reliably to body
<html className={inter.className}>
<body>{children}</body>
</html>className (the font-family stack) goes on <body>. variable (the CSS custom property) goes on <html> so child elements can reference it. The correct split:
<html className={inter.variable}>
<body className={inter.className}>4. Wrong path for local fonts
// Wrong: path relative to project root doesn't work
const font = localFont({ src: '../public/fonts/MyFont.woff2' });next/font/local resolves paths relative to the file where the font is defined, not the project root and not public/. Put WOFF2 files in app/fonts/ next to your layout, then:
const font = localFont({ src: './fonts/MyFont-Regular.woff2' });5. Missing axes for variable fonts
// Wrong: only one static weight downloaded, variable axis ignored
const jetbrains = JetBrains_Mono({ subsets: ['latin'], weight: '400' });
// fontVariationSettings at runtime won't interpolate other weightsAdd axes: ['wght'] to enable the full weight range from one WOFF2 file. Without it, Next.js downloads only the static weight you specified and the variable axis does nothing.
Real-world usage
- Vercel's own site uses Inter and SF Pro via
next/fontfor zero CLS on marketing pages - Shadcn/ui scaffolding sets up Inter with a CSS variable for Tailwind theming out of the box
- The create-t3-app template uses local fonts for admin dashboard layouts
- Any Next.js project targeting 90+ Lighthouse performance scores needs CLS below 0.1;
next/fontis the direct path there
Follow-up questions
Q: What does font-display: swap actually do?
A: It tells the browser to show the fallback font immediately and swap it for the custom font once loaded. The swap window is up to 3 seconds. Without it you'd get either invisible text (block) or the fallback font staying permanently (fallback/optional).
Q: What is the difference between className and variable?
A: className injects a font-family declaration directly onto the element. variable creates a CSS custom property like --font-inter that you reference in your CSS or Tailwind config. Use variable when fonts are managed through a design system; className works for quick one-off application.
Q: How does size-adjust prevent layout shift?
A: Next.js reads the font's vertical metrics (ascent ratio, descent ratio, line-gap) at build time and calculates a percentage to scale the fallback font so its text dimensions match the custom font. When the swap happens, element sizes don't change, so CLS stays at zero.
Q: Does next/font work in the Pages Router?
A: Yes. In the App Router you configure it in app/layout.tsx. In Pages Router you do it in _app.tsx or _document.tsx. App Router is preferred because it gives full <head> preload behavior, but both approaches work.
Q (senior): A user reports text jumping on mobile Safari. You traced it to next/font. Walk through how you'd diagnose and fix it.
A: First reproduce it: run Lighthouse in mobile mode in Chrome DevTools, check the CLS score and identify which elements shift. Then check whether it is a variable font with axes defined. iOS Safari below 15.4 has partial variable axis support, so weights set via fontVariationSettings may not interpolate correctly. Fix: specify weight: ['100', '400', '700'] as a static array fallback. Also check adjustFontFallback: on iOS the default system fallback can be SF Pro rather than Arial, which has different metrics. Override it with adjustFontFallback: 'Arial' for a consistent size-adjust calculation across platforms. Junior answer is just "use swap"; the actual fix requires reproducing the issue, checking font axis support, and auditing fallback font metrics.
Examples
Google Fonts with Tailwind CSS integration
A production layout with two Google Fonts wired into Tailwind via CSS variables:
// app/layout.tsx
import { Inter, Roboto_Mono } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
display: 'swap',
variable: '--font-sans',
});
const mono = Roboto_Mono({
subsets: ['latin'],
weight: '400',
display: 'swap',
variable: '--font-mono',
});
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={`${inter.variable} ${mono.variable}`}>
<body className={inter.className}>
{children}
</body>
</html>
);
}// tailwind.config.ts
module.exports = {
theme: {
extend: {
fontFamily: {
sans: ['var(--font-sans)', 'system-ui'],
mono: ['var(--font-mono)', 'monospace'],
},
},
},
};Both fonts download at build time as subsets only. CSS variables flow into Tailwind's font-sans and font-mono utilities, so any component in the tree can use className="font-sans" or className="font-mono" without importing the font again.
Local font with multiple weights
When you have a custom branded font in WOFF2 format that is not on Google Fonts:
// app/layout.tsx
import localFont from 'next/font/local';
const brand = localFont({
src: [
{ path: './fonts/Brand-Regular.woff2', weight: '400', style: 'normal' },
{ path: './fonts/Brand-Bold.woff2', weight: '700', style: 'normal' },
{ path: './fonts/Brand-Italic.woff2', weight: '400', style: 'italic' },
],
display: 'swap',
variable: '--font-brand',
});
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={brand.variable}>
<body className={brand.className}>
{children}
</body>
</html>
);
}Font files sit in app/fonts/ relative to layout.tsx. Next.js picks them up at build time, copies them into the output, and generates one @font-face rule per weight and style combination. No manual preload links needed.
Variable font with weight axis
One WOFF2 file can serve every weight if the font supports variable axes:
// app/layout.tsx
import { JetBrains_Mono } from 'next/font/google';
const jetbrains = JetBrains_Mono({
subsets: ['latin'],
axes: ['wght'], // enables the variable weight axis
variable: '--font-code',
display: 'swap',
});// components/CodeBlock.tsx
export function CodeBlock({ children }: { children: React.ReactNode }) {
return (
<pre
className="font-code"
style={{ fontVariationSettings: '"wght" 500' }}
>
{children}
</pre>
);
}Without axes: ['wght'], Next.js downloads a single static weight and fontVariationSettings does nothing at runtime. With it, the one variable WOFF2 handles any weight in the font's supported range, and the bundle is smaller than loading three or four separate weight files.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.