Next.js Font Optimization with next/font

This guide is part of the Loading Web Fonts in JavaScript Frameworks topic, itself part of the Font Loading & Delivery Strategies area.

A typical Next.js app that imports a Google Font via a <link> tag in _document.tsx pays three separate costs: a render-blocking stylesheet request to fonts.googleapis.com, a second round trip to fonts.gstatic.com for the actual WOFF2 file, and — because the fallback glyph metrics rarely match the web font's — a burst of cumulative layout shift the instant the real font swaps in. The next/font module, built into Next.js since 13.2 (it superseded the earlier @next/font package), removes all three costs by downloading the font files at build time, self-hosting them from your own domain, and generating a metrics-matched fallback @font-face automatically. No request ever leaves your origin, and no manual size-adjust calculation is required.

Prerequisites

  • Next.js 13.2 or later (the next/font import path is bundled with the framework; no separate package install is needed for Google Fonts).
  • For self-hosted files, the font binaries (.woff2, .ttf) placed under your project, typically in a fonts/ or public/fonts/ directory.
  • An understanding of how font-display values change perceived load behavior — if you have not already, read font-display Values: swap, optional, fallback Explained before choosing the display option below.
  • Familiarity with why fallback-metric matching matters: see Using ascent-override and descent-override to Reduce CLS, since next/font's adjustFontFallback option is doing exactly that calculation for you under the hood.

Implementation

The canonical pattern loads a Google Font once, in a shared module, and exports the generated class name and CSS variable for use across layouts:

// app/fonts.ts
import { Inter, Roboto_Mono } from 'next/font/google';

export const inter = Inter({
  subsets: ['latin'],
  display: 'swap',
  variable: '--font-inter',
  preload: true,
  adjustFontFallback: true, // default; generates a metric-matched fallback
});

export const robotoMono = Roboto_Mono({
  subsets: ['latin'],
  weight: ['400', '500'],
  display: 'swap',
  variable: '--font-mono',
  preload: false, // secondary font: skip the preload budget
});
// app/layout.tsx
import { inter, robotoMono } from './fonts';
import './globals.css';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" className={`${inter.variable} ${robotoMono.variable}`}>
      <body>{children}</body>
    </html>
  );
}
/* globals.css */
body {
  font-family: var(--font-inter), system-ui, sans-serif;
}
code, pre {
  font-family: var(--font-mono), monospace;
}

Line by line: Inter({...}) is called once at build time — the Next.js compiler statically analyzes this call, so it must be a top-level invocation with literal arguments, not something computed at runtime. subsets: ['latin'] tells the underlying fontaine/Google Fonts metadata fetch which Unicode ranges to download; omitting it downloads every subset the family ships, often 3–5× the bytes you need (the same trade-off covered in How unicode-range Reduces Font Payload). display: 'swap' sets the font-display descriptor on the generated @font-face, giving text an immediate fallback render with a swap the moment the WOFF2 arrives — appropriate for body copy where invisible text is worse than a brief reflow. variable: '--font-inter' switches the API into CSS-variable mode: instead of a generated utility class you get a custom property scoped to whichever element carries inter.variable, which composes cleanly with Tailwind's fontFamily: { sans: ['var(--font-inter)'] } config. preload: true (the default) injects a <link rel="preload" as="font" crossorigin> into the document head for this specific font file — but only in the route segments where the font is actually rendered, because Next.js tracks usage per-layout and avoids preloading fonts that a given page never displays. Setting preload: false on the monospace font stops Next.js competing for preload priority on a typeface that's below the fold on most pages; see Font Preloading & Resource Hints Guide for how preload priority interacts with LCP-critical requests generally. adjustFontFallback: true is the key CLS control: Next.js reads Inter's ascent, descent, line-gap, and average character width, computes the closest matching system fallback (Arial on most platforms), and emits a synthetic @font-face for "Inter Fallback" with size-adjust, ascent-override, and descent-override values tuned so the fallback box occupies the same pixel height as the real Inter render — the exact goal described in Calculating size-adjust for a system-ui Fallback, done for you at build time instead of by hand.

next/font build-time pipeline Layered diagram showing the next/font module fetching the font file, computing fallback metrics, and emitting self-hosted CSS during the Next.js build. next/font build-time pipeline Inter({...}) call build time Font file fetched once Google metadata API Fallback metrics computed adjustFontFallback Self-hosted @font-face + preload shipped to client
next/font resolves fonts and fallback metrics at build time, not in the browser.

Local fonts follow the same shape but skip the network fetch entirely, which matters for licensed weights you cannot pull from Google's CDN:

// app/fonts.ts
import localFont from 'next/font/local';

export const brand = localFont({
  src: [
    { path: '../public/fonts/Brand-Regular.woff2', weight: '400', style: 'normal' },
    { path: '../public/fonts/Brand-Bold.woff2', weight: '700', style: 'normal' },
  ],
  display: 'swap',
  variable: '--font-brand',
  fallback: ['system-ui', 'arial'],
  adjustFontFallback: 'Arial', // force the metric source for the fallback calc
});

localFont takes an array of src entries rather than fetching from Google's metadata API, so each weight and style you actually ship must be listed explicitly — there is no automatic "give me every static instance" behavior. fallback supplies the ordered list Next.js appends after your generated fallback name, exactly like an ordinary font-family stack. adjustFontFallback: 'Arial' (or 'Times New Roman', or false to disable) lets you pick which system font's metrics to measure against when the automatic OS-detection heuristic picks the wrong one for a display font whose proportions diverge sharply from Arial's — display and script faces are the case where you most often need to override this.

Defensive variant: variable fonts and axis ranges

Google's variable-font files expose a weight range rather than fixed cuts, and next/font/google passes that straight through — but only if you ask for the variable axis correctly:

// app/fonts.ts
import { Inter } from 'next/font/google';

export const inter = Inter({
  subsets: ['latin'],
  display: 'optional', // no swap flash at all; falls back permanently if slow
  weight: 'variable', // request the full wght range, not fixed cuts
  variable: '--font-inter',
});

weight: 'variable' is only valid for families Google publishes as variable fonts (Inter, Roboto Flex, many others); requesting it on a static-only family throws a build-time error, which is a useful guardrail — it fails your CI build rather than silently falling back to a static weight in production. Switching display to 'optional' changes the resilience posture: if the font has not finished downloading within roughly 100ms of the fallback render, the browser keeps the fallback for that page view and only swaps to the web font on a subsequent navigation once it's cached — appropriate for a marketing shell where a mid-scroll weight change would be more jarring than staying on the fallback. This is the same trade-off discussed for hand-rolled implementations in Variable Font Loading Techniques & Best Practices; next/font does not remove the decision, it just removes the manual @font-face boilerplate around it.

display option vs swap behavior Comparison table of font-display values available through next/font and their block period, swap behavior, and CLS risk. display option vs swap behavior Block period Swap risk CLS risk swap 0ms Yes, visible Low w/ adjust optional ~100ms Rare Near zero auto Browser default Yes Medium
Each font-display value trades invisible text against a visible swap.

Verification

Confirm self-hosting and zero-CLS behavior with three checks:

  1. Network tab: open DevTools, filter by Font, and reload. Every font request should resolve against your own origin (e.g. /_next/static/media/e4af272ccee01ff0-s.p.woff2) — if you see a request to fonts.gstatic.com, the font call is not statically analyzable (often because it's inside a conditional or a non-top-level function) and Next.js has fallen back to a runtime fetch.
  2. View source on the generated CSS: search for size-adjust in the <style> block Next.js inlines — its presence confirms adjustFontFallback produced a synthetic fallback face rather than leaving the browser's default metrics in place.
  3. Layout Shift trace: record a Performance profile in DevTools while throttling to Slow 4G, then check the Experience track for layout-shift entries during the font swap window. With adjustFontFallback enabled and a correctly chosen fallback source, the shift contribution from the font swap should read 0.00 or a value below 0.01; a full walkthrough of reading sources[] on a shift entry is in Debugging Font-Related Layout Shift (CLS).
Layout shift with adjustFontFallback Meter showing measured cumulative layout shift from a font swap staying well under the 0.1 CLS budget threshold when adjustFontFallback is enabled. Layout shift with adjustFontFallback 0 0.1 CLS 0.01 CLS measured budget 0.1 CLS
A metric-matched fallback keeps the font swap's CLS contribution near zero.

Common Pitfalls

  • Calling the font function conditionally or inside a component. Inter({...}) must be invoked once at module scope; wrapping it in if (isDark) Inter({...}) breaks Next's static analysis and the font silently reverts to a client-side network fetch, reintroducing the exact render-blocking request the module exists to avoid.
  • Forgetting subsets. Omitting subsets on next/font/google downloads the family's full character set — for Noto Sans CJK-adjacent families this can mean multiple megabytes versus the 20–40KB a latin subset needs. Always declare the subsets your content actually uses.
  • Setting preload: true on every font in a multi-font layout. Each preloaded font competes for the browser's limited early-fetch bandwidth. Preload only the font used in your LCP element (typically the heading or hero font) and set preload: false on secondary/decorative faces.
  • Skipping adjustFontFallback because "it just works." The default is true, but teams sometimes disable it to "simplify the generated CSS" — doing so brings back the exact CLS the module is designed to eliminate, since the browser then uses raw OS-default fallback metrics with no size correction.
  • Mixing next/font with a manual <link rel="preload"> for the same file. Next.js already emits its own preload tag per usage; adding a hand-written duplicate wastes a connection slot on a redundant fetch of a file that's already prioritized.

Working with multiple layouts and route groups

Large Next.js apps rarely load every font in every route. A marketing shell might use a display serif for headings while the authenticated dashboard uses only a UI sans-serif; loading both fonts globally wastes bytes on routes that never render one of them. The fix is to scope each font module to the layout that actually needs it rather than declaring every font in the root app/layout.tsx:

// app/(marketing)/fonts.ts
import { Playfair_Display } from 'next/font/google';

export const display = Playfair_Display({
  subsets: ['latin'],
  weight: ['700'],
  display: 'swap',
  variable: '--font-display',
});
// app/(marketing)/layout.tsx
import { display } from './fonts';

export default function MarketingLayout({ children }: { children: React.ReactNode }) {
  return <div className={display.variable}>{children}</div>;
}

Because Next.js tracks font usage per compiled layout segment, the <link rel="preload"> for Playfair_Display is emitted only on requests that render the (marketing) route group — a request to /dashboard never downloads or preloads it. This is the same segment-level code-splitting Next.js already applies to JavaScript chunks, applied to font assets: each layout's font module is a build-time constant scoped to that subtree, not a global side effect that leaks into every page. The practical payoff shows up directly in the request waterfall — a dashboard route with only a UI sans-serif has one preloaded font instead of three, which matters most on the mobile mid-tier devices where every extra render-blocking preload delays the largest contentful paint by tens of milliseconds.

A second consideration in multi-font setups is fallback stacking order. When two font modules both declare variable, the resulting custom properties compose independently — --font-display and --font-inter do not conflict — but the fallback chain each one generates is still evaluated per-element, so a heading styled with font-family: var(--font-display), serif falls back to a generic serif, not to Inter's fallback, if the display font fails to load. Keep each font's declared fallback array (or the generic keyword you append in CSS) matched to that font's own visual category, rather than assuming a single shared fallback will read correctly across a serif heading and a sans-serif body.

Frequently Asked Questions

Does next/font work with fonts outside Google Fonts? Yes — next/font/local accepts any font file you check into your repository or reference by relative path, including licensed commercial fonts, custom icon fonts, or variable fonts you've subsetted yourself. It applies the same preloading, CSS-variable, and adjustFontFallback machinery as next/font/google; the only difference is that you supply the src array instead of a family name, since there's no Google metadata API to query for weight and subset options.

Can I still use next/font with the Pages Router, not just the App Router? Yes. The import paths (next/font/google, next/font/local) and the options are identical in the Pages Router; you typically call the font function in _app.tsx and apply the returned class name to a top-level wrapper element, since the Pages Router does not have the variable-on-<html> composition pattern shown above by default — you can still use variable mode, just apply the class in _app.tsx's root render instead of layout.tsx.

Does self-hosting through next/font affect Google Fonts' own CDN caching benefits? It removes them, and that is the point in most cases: the shared-cache theory for Google Fonts (visitors already having Roboto cached from another site) collapsed once browsers moved to partitioned HTTP caches, so a cross-site cache hit no longer happens — see HTTP Cache Partitioning and Cross-Site Font Sharing for the mechanism. Self-hosting via next/font trades that already-diminished benefit for a same-origin request with no extra DNS lookup, no separate TLS handshake to fonts.gstatic.com, and a Cache-Control: immutable header Next.js sets automatically on the hashed output file.

How does next/font choose which weights to download for a variable Google Font? When you specify weight: 'variable', Next.js downloads the full variable-font WOFF2 as published by Google, which already spans its registered axis range (commonly 100–900 for wght). If you instead pass an array like weight: ['400', '700'] for a family that has both static and variable builds, Next.js downloads the static instances at exactly those cuts rather than the variable file — smaller per-request payload if your design only ever uses two weights, at the cost of losing interpolation for any weight in between.

Related