Skip to main content

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/font downloads 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/font fixes both
  • className applies a font-family stack directly to an element; variable creates a CSS custom property like --font-inter for use in Tailwind or globals.css
  • display: 'swap' shows the fallback font immediately, then swaps when the custom font loads
  • Use next/font for any font in a production Next.js app; skip only for non-JS static sites where CLS is not a concern

Quick example

tsx
// 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> automatically

html 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/local with 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 to next/font before 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

OptionWhat it doesExample
subsetsCharacter sets to include in the download['latin', 'cyrillic']
weightSpecific weights to load['400', '700']
displayCSS font-display value'swap' (recommended)
variableName for the CSS custom property'--font-inter'
preloadWhether to add <link rel="preload">true (default)
adjustFontFallbackOverride the fallback font for size-adjust calc'Arial'
axesEnable variable font axes['wght']

Common mistakes

1. Wrong subset for non-Latin text

tsx
// Wrong: only latin glyph data downloaded at build time const inter = Inter({ subsets: ['latin'] }); // <p>Привет</p> renders as squares or invisible text

Fix: 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

tsx
// 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>

tsx
// 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:

tsx
<html className={inter.variable}> <body className={inter.className}>

4. Wrong path for local fonts

tsx
// 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:

tsx
const font = localFont({ src: './fonts/MyFont-Regular.woff2' });

5. Missing axes for variable fonts

tsx
// Wrong: only one static weight downloaded, variable axis ignored const jetbrains = JetBrains_Mono({ subsets: ['latin'], weight: '400' }); // fontVariationSettings at runtime won't interpolate other weights

Add 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/font for 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/font is 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:

tsx
// 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> ); }
ts
// 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:

tsx
// 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:

tsx
// 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', });
tsx
// 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 ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?