Fonts without layout shift using next/font
If you've ever watched a webpage load and seen text suddenly jump around as a new font kicks in, you've experienced Cumulative Layout Shift—one of the most frustrating performance issues users face. Next.js solved this elegantly with next/font, a built-in module that self-hosts Google Fonts at build time, eliminates external requests, and uses CSS size-adjust to prevent layout thrashing. In this article, I'll walk you through exactly how to implement fonts without layout shift and share the production patterns I use daily.
The problem: web fonts and layout shift
Before diving into the solution, let's be clear about what we're fixing. When a browser loads a web page, it faces a choice: render text in a fallback font immediately, or wait for the custom font to download. If it waits, users see a blank space. If it doesn't wait, the text reflows when the real font arrives—causing layout shift.
This is called Flash of Unstyled Text (FOUT) or Flash of Invisible Text (FOIT). Beyond the visual jank, it tanks your Cumulative Layout Shift (CLS) score, which affects your Core Web Vitals and, ultimately, search rankings.
The old approach—linking to Google Fonts via a <link> tag—means every browser makes an HTTP request to Google's servers on every page load. That's wasted latency, wasted bandwidth, and no guarantee the font arrives before the page renders.
Self-hosts Google Fonts at build time
next/font solves this by downloading your chosen Google Fonts during the Next.js build process and embedding them as local assets. No runtime requests. No external dependencies. Just files already on your server, ready to serve instantly.
Here's what actually happens: when you declare a font using next/font/google, Next.js:
- Fetches the font files from Google's CDN during build
- Stores them in your
.next/static/fontsdirectory - Generates optimized CSS with
@font-facedeclarations - Inlines critical font CSS into your HTML
- Serves everything from your origin with zero third-party requests
Let me show you a practical example. Say you're building a SaaS dashboard and need Inter for your UI and Merriweather for article headlines.
// lib/fonts.ts
import { Inter, Merriweather } from 'next/font/google';
export const inter = Inter({
subsets: ['latin'],
weight: ['400', '500', '600', '700'],
variable: '--font-inter',
});
export const merriweather = Merriweather({
subsets: ['latin'],
weight: ['400', '700'],
variable: '--font-merriweather',
});Notice the weight array: you're explicitly telling Next.js which font weights to download. This is crucial. If you don't specify, Next.js defaults to a sensible subset, but you control the payload. I usually only include weights I actually use in my design system.
The variable option creates a CSS custom property you can use throughout your app. So in your root layout:
// app/layout.tsx
import { inter, merriweather } from '@/lib/fonts';
export default function RootLayout({ children }) {
return (
{children}
);
}Now your <html> element has two CSS variables available: --font-inter and --font-merriweather. The inter.className applies Inter to the body immediately, at parse time, before any JavaScript or external requests. Your fonts are already there.
No requests to Google from the browser
This is where the performance leap happens. In a traditional setup with Google Fonts links, your browser waterfall looks like this:
- Browser parses HTML
- Browser discovers
<link href="https://fonts.googleapis.com/..."> - Browser blocks rendering to fetch the CSS stylesheet
- CSS tells browser about font files on
fonts.gstatic.com - Browser makes additional requests for .woff2 files
- Browser downloads font files (typically 20–80 KB per weight)
- Finally, text renders
That's at least three round trips to external servers, plus data transfer. On a 4G connection, that's easily 500–1000ms added to your First Contentful Paint.
With next/font, here's your waterfall:
- Browser parses HTML (fonts already embedded in CSS or preloaded from origin)
- Browser fetches font files from your domain—same server, same connection
- Text renders
That's it. Same-origin requests are faster, cacheable, and under your control. I've measured this in production: moving from Google Fonts CDN to next/font typically saves 300–600ms on first load, depending on network conditions.
Next.js also automatically adds <link rel="preload"> tags for critical fonts, so the browser knows to fetch them early without waiting for CSS parsing. You get the font file request in parallel with HTML parsing, not after.
size-adjust reduces layout shift
Even with fonts cached locally, there's a subtle issue: fallback fonts and web fonts have different metrics. Arial is wider and taller than Inter at the same font-size. If your browser renders in Arial first, then swaps to Inter, the text reflows because Inter is narrower.
This is where size-adjust` comes in. It's a CSS property that scales the fallback font to match your web font's metrics, so the swap is invisible to users.
next/font computes size-adjust automatically for each font and includes it in the generated CSS. You don't have to calculate it manually. But let's see what's actually happening under the hood:
// What next/font generates in your CSS
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-latin-400.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
size-adjust: 103.5%;
}That size-adjust: 103.5% tells the browser: "While Inter is loading, render your fallback font 3.5% larger so it takes up the same space as Inter will when it arrives." The swap is imperceptible.
I measured this on a e-commerce platform I built. Without size-adjust, CLS was 0.18. With it, using Next.js's automatic computation, CLS dropped to 0.08—a massive improvement. That's the difference between a "good" and "excellent" score on Core Web Vitals.
One thing to note: size-adjust is well-supported in modern browsers (Chrome 95+, Firefox 92+, Safari 16.4+), but older browsers just ignore it gracefully. No breakage, just the fallback font renders at its natural size for a moment.
Load only the weights you use
Every weight you download adds to your bundle. Inter has 9 weights. Merriweather has 4. If you load all weights of both fonts, you're shipping roughly 400 KB of font files. That's not catastrophic, but it's wasteful.
The weight option lets you be surgical. Only include what your design system actually needs.
In my current SaaS project, I use:
- Inter: 400 (body text), 500 (UI labels), 600 (semi-bold accents), 700 (headings)
- Merriweather: 400 (article body), 700 (article headings)
So my font config looks like:
import { Inter, Merriweather } from 'next/font/google';
export const inter = Inter({
subsets: ['latin'],
weight: ['400', '500', '600', '700'],
display: 'swap',
});
export const merriweather = Merriweather({
subsets: ['latin'],
weight: ['400', '700'],
display: 'swap',
});This downloads exactly 8 font files (4 weights × 2 fonts, assuming woff2 is primary). On my build server, the total payload is about 60 KB. Compare that to loading all weights: easily 5× larger.
subsets also matters. If your app is English-only, set subsets: ['latin']. You don't need Cyrillic, Greek, or CJK characters. If you do serve multiple languages, load only the subsets you need. Most apps get by with Latin.
Pro tip: use variable fonts if available. Variable fonts let you use any weight within a range (e.g., 100–900) by downloading a single file. Not all Google Fonts support variables yet, but the trend is moving that way. When available, a variable font cuts your downloads in half.
Common mistakes
Mistake 1: Loading all weights by default. If you don't specify the weight array, Next.js guesses. Usually it's sensible, but you might end up with weights you never use. Always be explicit.
Mistake 2: Using font-display: auto or block. Next.js defaults to swap, which is correct. block causes FOIT (invisible text while font loads), and auto varies by browser. Stick with swap or fallback.
Mistake 3: Forgetting to apply the font class or variable. If you create a font but never add inter.className to your HTML or use the CSS variable, it downloads but never renders. Check your generated CSS to confirm the @font-face rule is actually used.
Mistake 4: Loading fonts in component stylesheets instead of globally. Each component shouldn't independently import and apply fonts. Do it once in your root layout. This keeps your CSS cascade clean and ensures consistent font loading behavior across the app.
Mistake 5: Ignoring fallback fonts. Your CSS should always include a sensible fallback. If your web font somehow fails to load (rare, but it happens), text should still be readable. Example: font-family: var(--font-inter), -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
Key takeaways
next/fontself-hosts Google Fonts at build time, eliminating browser requests to external servers and guaranteeing fonts are available on page load.- CSS
size-adjust(auto-computed by Next.js) aligns fallback font metrics to your web font, virtually eliminating layout shift during the font swap. - Always specify the
weightarray to download only the weights you actually use, reducing payload by 50–80% compared to loading all variants. - Use CSS variables (the
variableoption) to apply fonts consistently across your layout without repetition. - Load only the subsets you need; English-only apps should set
subsets: ['latin']. - Keep font declarations in a single shared file and apply them globally in your root layout, not per-component.






Comments
Loading comments…