Typography is one of the most overlooked performance factors in Next.js apps. Fonts are typically render-blocking — the browser can't display styled text until the font file downloads, leading to either invisible text (FOIT) or a flash of unstyled text (FOUT), and potentially Cumulative Layout Shift if the fallback font's dimensions differ from the loaded font.
Next.js solves this with the next/font module. This covers how it works, the configuration that matters, and the gotchas you'll hit in production.
What next/font Does Differently
Traditional font loading: your CSS requests a font from Google Fonts, the browser fetches it from Google's CDN, and the font renders. This involves an external network request and gives Google visibility into your users' browsers.
next/font intercepts this at build time:
- Downloads the font file during the build
- Self-hosts it alongside your application
- Injects optimized
@font-facedeclarations - Generates a CSS variable or className with the correct font settings The result: no external font requests at runtime, no layout shift, no FOIT/FOUT, and no third-party tracking from font CDNs.
Google Fonts Integration
// app/layout.js
import { Inter, Playfair_Display } from 'next/font/google'
const inter = Inter({
subsets: ['latin'],
display: 'swap', // 'auto' | 'block' | 'swap' | 'fallback' | 'optional'
variable: '--font-inter', // CSS variable name
})
const playfair = Playfair_Display({
subsets: ['latin'],
weight: ['400', '700'],
style: ['normal', 'italic'],
variable: '--font-playfair',
})
export default function RootLayout({ children }) {
return (
<html lang="en" className={`${inter.variable} ${playfair.variable}`}>
<body>{children}</body>
</html>
)
}
Then in your CSS:
/* globals.css */
body {
font-family: var(--font-inter), sans-serif;
}
h1, h2, h3 {
font-family: var(--font-playfair), serif;
}
Or use the className approach directly:
const inter = Inter({ subsets: ['latin'] })
// Apply directly to the element
<p className={inter.className}>Text here</p>
Subset Selection
Fonts contain character sets for every language they support. Loading the full font is wasteful if your app is English-only. The subsets option restricts which character sets are included:
const inter = Inter({
subsets: ['latin'], // English, Western European
// subsets: ['latin', 'latin-ext'], // + Extended Latin
// subsets: ['latin', 'cyrillic'], // + Russian, etc.
})
Google Fonts documents available subsets for each font. Restricting to ['latin'] for English-only apps reduces font file size significantly.
font-display Strategy
The display option controls how the browser handles the period between when the font is requested and when it loads:
const inter = Inter({
subsets: ['latin'],
display: 'swap', // Recommended for body text
})
The options:
-
swap— shows fallback immediately, swaps when loaded. CLS risk if metrics differ significantly. -
optional— gives the font a very short window (100ms). If not loaded, uses fallback permanently. Best for non-critical fonts on slow connections. -
block— blocks rendering briefly. Avoids FOUT but can cause invisible text. -
fallback— short block period, then fallback. Middle ground. For most body text:swap. For fonts where you'd rather use the fallback than cause a visible swap:optional.
Size Adjustments and Fallback Optimization
The CLS risk with display: swap comes from fallback font metrics differing from the web font. Next.js handles this automatically with adjustFontFallback and generates a fallback font that matches the web font's line height, size adjust, and ascent/descent.
You can also define custom fallback behavior:
const roboto = Roboto({
weight: '400',
subsets: ['latin'],
display: 'swap',
adjustFontFallback: true, // Enabled by default
fallback: ['Helvetica Neue', 'Arial', 'sans-serif'],
})
Self-Hosted Local Fonts
For proprietary fonts or fonts not available on Google:
import localFont from 'next/font/local'
const brandFont = localFont({
src: [
{
path: '../public/fonts/brand-regular.woff2',
weight: '400',
style: 'normal',
},
{
path: '../public/fonts/brand-bold.woff2',
weight: '700',
style: 'normal',
},
],
variable: '--font-brand',
display: 'swap',
})
The font files go in public/fonts/. Prefer .woff2 — it's the most compressed format with universal modern browser support.
Tailwind CSS Integration
If you're using Tailwind, map the CSS variables to Tailwind's fontFamily config:
// tailwind.config.js
module.exports = {
theme: {
extend: {
fontFamily: {
sans: ['var(--font-inter)', 'sans-serif'],
serif: ['var(--font-playfair)', 'serif'],
},
},
},
}
Then use normally in your markup:
<h1 class="font-serif text-4xl">Heading</h1>
<p class="font-sans text-base">Body text</p>
Production Gotchas
Font files are emitted at build time. If you change font configuration, you need to rebuild. Dev server changes don't always hot-reload font configurations — restart if fonts aren't applying.
The variable option is required for CSS variable usage. Without it, you only get the className approach. If you want global font variables accessible across CSS files, set variable.
Google Fonts requires internet during build. In CI environments without outbound internet access, fonts won't download and the build fails. Either pre-download fonts and use localFont, or ensure your build environment has outbound internet.
Multiple weights increase file size proportionally. Each weight you request is a separate font file. Only load the weights you actually use.
Measuring the Impact
Before optimizing fonts, capture your baseline:
- CLS score in Core Web Vitals — font swap is one of the most common CLS causes
-
Network tab in DevTools — look for requests to
fonts.googleapis.comorfonts.gstatic.com -
Third-party requests in PageSpeed Insights — external font CDN requests show up here
After
next/fontimplementation, you should see: third-party font requests eliminated, CLS reduced toward zero from font swap, and LCP potentially improved if the font was previously render-blocking.
For AI-generated content like Pixova's steampunk art generator, typography choices for the blog significantly affect both aesthetics and Core Web Vitals — both worth optimizing.
Top comments (0)