Font Optimization in Next.js
next/font
LinkedIn Hook
"Why does your website flicker every time a new page loads?"
That ugly text jump you see — where system fonts swap to custom fonts mid-render — has a name. It's called layout shift, and it's silently destroying your Core Web Vitals score.
Most developers load Google Fonts with a
<link>tag in<head>and call it a day. But that approach creates a network request to Google's servers, blocks rendering, leaks user privacy data, and causes the dreaded flash of unstyled text.Next.js solved this with
next/font— a built-in system that downloads fonts at build time, self-hosts them from your own domain, and appliessize-adjustto guarantee zero layout shift. No external requests. No FOUT. No CLS penalty.In Lesson 7.2, I break down how
next/font/google,next/font/local, and variable fonts work under the hood — and why interviewers love asking about this.Read the full lesson -> [link]
#NextJS #WebPerformance #FontOptimization #CoreWebVitals #FrontendDevelopment #InterviewPrep
What You'll Learn
- How
next/font/googledownloads and self-hosts Google Fonts at build time - How
next/font/localloads custom font files from your project - What variable fonts are and why they outperform static font weights
- The CSS variable approach for sharing fonts across components
- How Next.js eliminates FOUT (Flash of Unstyled Text) and FOIT (Flash of Invisible Text)
- Font subsetting — serving only the characters your site actually uses
- Preloading strategies and how Next.js handles them automatically
The Uniform Analogy — Why Font Loading Matters
Imagine a school that requires uniforms. On the first day of class, students arrive in their regular clothes. When uniforms finally arrive mid-morning, every student changes — and suddenly the hallway looks completely different. People shift positions, heights look different, everything rearranges.
That is exactly what happens on a webpage when fonts load late. The browser first renders text in a fallback system font (Arial, Times New Roman). When the custom font file finally downloads, every single piece of text reflows — paragraphs change height, buttons shift, headings resize. The entire page jumps. This is Cumulative Layout Shift (CLS), and Google penalizes it in search rankings.
Now imagine the school sends uniforms to every student's home before the first day. Everyone arrives already dressed. No changes, no shifting, no chaos.
That is what next/font does. It downloads fonts at build time, bundles them with your application, and applies size-matching so the fallback font takes up the exact same space as the final font. By the time the page renders, everything is already in place.
+---------------------------------------------------------------+
| TRADITIONAL FONT LOADING (The Problem) |
+---------------------------------------------------------------+
| |
| 1. Browser requests HTML |
| 2. HTML contains <link> to fonts.googleapis.com |
| 3. Browser makes ANOTHER request to Google's CDN |
| 4. DNS lookup + connection + download of .woff2 files |
| 5. Browser renders text in fallback font (Arial) |
| 6. Font file arrives -> text REFLOWS -> layout shift! |
| |
| User sees: "Hello" in Arial --> FLASH --> "Hello" in Inter |
| ^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^ |
| different width! final width |
| |
+---------------------------------------------------------------+
+---------------------------------------------------------------+
| NEXT.JS FONT LOADING (The Solution) |
+---------------------------------------------------------------+
| |
| 1. At BUILD TIME: Next.js downloads font from Google |
| 2. Font file is saved to your project (self-hosted) |
| 3. Next.js calculates size-adjust for fallback matching |
| 4. Browser requests HTML |
| 5. Font is served from same domain (no external request) |
| 6. Fallback font matches final font dimensions exactly |
| 7. Zero layout shift. Zero FOUT. Zero CLS penalty. |
| |
| User sees: "Hello" in Inter (instantly, no flash) |
| |
+---------------------------------------------------------------+
Napkin AI Visual Prompt: "Dark gradient (#0a0e1a -> #111827). Split comparison: LEFT side labeled 'Traditional' shows a browser with text jumping (red arrows indicating layout shift, red #ef4444 highlights). RIGHT side labeled 'next/font' shows the same browser with perfectly stable text (green #10b981 checkmarks, green alignment guides). A timeline below shows the sequence: DNS -> Download -> Swap (traditional, slow) vs Build -> Serve -> Stable (next/font, fast). White monospace labels. Purple (#8b5cf6) divider between the two sides."
Using next/font/google — Google Fonts Without the Google Request
The most common use case is loading fonts from Google's collection. With next/font/google, you import the font as a function, configure it, and apply it — all without a single <link> tag to Google's servers.
Basic Setup in Root Layout
// app/layout.tsx
// Import the font function from next/font/google
import { Inter } from 'next/font/google';
// Initialize the font with configuration options
const inter = Inter({
subsets: ['latin'], // Only include Latin characters (smaller file)
display: 'swap', // Use fallback font until custom font loads
});
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
// Apply the font's className to the <html> element
// This makes the font available to the entire application
<html lang="en" className={inter.className}>
<body>{children}</body>
</html>
);
}
What happens at build time:
- Next.js sees the
Interimport and downloads the Inter font files from Google Fonts. - The
.woff2files are saved into your.next/staticdirectory. - Next.js generates a CSS
@font-facedeclaration with the local file path. - Next.js calculates a
size-adjustvalue so the fallback font matches Inter's dimensions. - The font is served from your own domain —
yourdomain.com/_next/static/...
No request to fonts.googleapis.com ever leaves the user's browser. No privacy leakage. No extra DNS lookups. No render-blocking external stylesheet.
Multiple Fonts in One Application
// app/layout.tsx
import { Inter, Fira_Code } from 'next/font/google';
// Primary font for body text
const inter = Inter({
subsets: ['latin'],
display: 'swap',
variable: '--font-inter', // Expose as CSS variable
});
// Monospace font for code blocks
const firaCode = Fira_Code({
subsets: ['latin'],
display: 'swap',
variable: '--font-fira-code', // Expose as CSS variable
});
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
// Apply both CSS variable classes to make them available everywhere
<html lang="en" className={`${inter.variable} ${firaCode.variable}`}>
<body className={inter.className}>{children}</body>
</html>
);
}
/* app/globals.css */
/* Use the CSS variables in your styles */
body {
font-family: var(--font-inter), system-ui, sans-serif;
}
code, pre {
font-family: var(--font-fira-code), monospace;
}
h1, h2, h3 {
font-family: var(--font-inter), system-ui, sans-serif;
font-weight: 700;
}
Using next/font/local — Custom Font Files
Not every font lives on Google's servers. Brand fonts, licensed typefaces, and custom designs need to be loaded from local files. next/font/local handles this.
Loading a Single Font File
// app/layout.tsx
import localFont from 'next/font/local';
// Load a single font file from the project
const myBrandFont = localFont({
src: './fonts/MyBrandFont-Regular.woff2', // Path relative to this file
display: 'swap',
variable: '--font-brand',
});
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className={myBrandFont.variable}>
<body className={myBrandFont.className}>{children}</body>
</html>
);
}
Loading Multiple Weights and Styles
When you have separate files for different weights (regular, bold, italic), you pass an array to src:
// app/layout.tsx
import localFont from 'next/font/local';
// Load multiple weights and styles from separate files
const myBrandFont = localFont({
src: [
{
path: './fonts/MyBrandFont-Regular.woff2',
weight: '400',
style: 'normal',
},
{
path: './fonts/MyBrandFont-Medium.woff2',
weight: '500',
style: 'normal',
},
{
path: './fonts/MyBrandFont-Bold.woff2',
weight: '700',
style: 'normal',
},
{
path: './fonts/MyBrandFont-Italic.woff2',
weight: '400',
style: 'italic',
},
],
display: 'swap',
variable: '--font-brand',
});
Important: Each weight/style combination is a separate file. The browser only downloads the weights your page actually uses, keeping the total download size minimal.
Variable Fonts — One File, Every Weight
Traditional fonts ship a separate file for each weight: Regular (400), Medium (500), Bold (700), etc. A site using three weights downloads three files. Variable fonts solve this by packing every weight from 100 to 900 into a single file.
Why Variable Fonts Matter
+---------------------------------------------------------------+
| STATIC FONTS vs VARIABLE FONTS |
+---------------------------------------------------------------+
| |
| STATIC FONTS (Traditional): |
| Regular.woff2 ~25 KB |
| Medium.woff2 ~25 KB |
| Bold.woff2 ~25 KB |
| Total: ~75 KB (3 HTTP requests) |
| Available weights: 400, 500, 700 only |
| |
| VARIABLE FONT: |
| Inter-Variable.woff2 ~30 KB |
| Total: ~30 KB (1 HTTP request) |
| Available weights: 100-900 (any value, like 450 or 623) |
| |
| Savings: 60% smaller, 2 fewer requests, infinite weights |
| |
+---------------------------------------------------------------+
Using Variable Fonts with next/font/google
Most popular Google Fonts are already variable fonts. When you import them, Next.js automatically uses the variable version:
// app/layout.tsx
import { Inter } from 'next/font/google';
// Inter is a variable font — this gives you ALL weights in one file
const inter = Inter({
subsets: ['latin'],
display: 'swap',
// You can specify a weight range for variable fonts
// If omitted, the full range (100-900) is included
});
Using Variable Fonts with next/font/local
// app/layout.tsx
import localFont from 'next/font/local';
// Load a variable font file with an explicit weight range
const myFont = localFont({
src: './fonts/MyFont-Variable.woff2',
display: 'swap',
variable: '--font-my',
// Declare the weight range this variable font supports
weight: '100 900',
});
The weight: '100 900' tells the browser this single file covers the entire weight spectrum. You can then use font-weight: 350 or any arbitrary value in your CSS — something impossible with static fonts.
The CSS Variable Approach — Sharing Fonts Across Components
Using className directly works for simple cases, but CSS variables give you more flexibility. They let you reference fonts anywhere in your stylesheets, Tailwind config, or CSS modules without importing the font object.
Setting Up CSS Variables
// app/layout.tsx
import { Inter, Playfair_Display } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
variable: '--font-sans', // Register as --font-sans
});
const playfair = Playfair_Display({
subsets: ['latin'],
variable: '--font-serif', // Register as --font-serif
});
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
// Both CSS variables are now available globally
<html lang="en" className={`${inter.variable} ${playfair.variable}`}>
<body>{children}</body>
</html>
);
}
Using in Tailwind CSS v4
/* app/globals.css */
@import "tailwindcss";
@theme {
--font-sans: var(--font-sans), system-ui, sans-serif;
--font-serif: var(--font-serif), Georgia, serif;
}
// Now use in any component — no font import needed
export default function ArticlePage() {
return (
<article>
<h1 className="font-serif text-4xl">The Art of Typography</h1>
<p className="font-sans text-lg">
Good typography is invisible. Bad typography is everywhere.
</p>
</article>
);
}
Using in CSS Modules
/* components/Hero.module.css */
.title {
font-family: var(--font-serif);
font-weight: 700;
font-size: 3rem;
}
.subtitle {
font-family: var(--font-sans);
font-weight: 400;
font-size: 1.25rem;
}
The CSS variable approach is the recommended pattern because it decouples font configuration (in layout.tsx) from font usage (in any component or stylesheet). You define fonts once at the root and consume them everywhere.
How Next.js Eliminates FOUT and FOIT
Two font-loading problems have plagued the web for decades:
- FOUT (Flash of Unstyled Text): The browser shows text in a fallback font, then swaps to the custom font when it loads. Text visibly changes appearance.
- FOIT (Flash of Invisible Text): The browser hides text entirely until the custom font loads. Users see blank space where text should be.
Next.js eliminates both through a three-part strategy:
1. Self-Hosting Eliminates Network Latency
Traditional approach: the browser must resolve DNS for fonts.googleapis.com, open a connection, download a CSS file, parse it, then download the actual .woff2 files from fonts.gstatic.com (another DNS lookup). This chain adds 200-500ms of latency.
With next/font, the font files are served from the same origin as your HTML. No extra DNS. No extra connection. The browser fetches them in parallel with other static assets.
2. Preloading Ensures Early Discovery
Next.js automatically adds <link rel="preload"> tags for fonts used in the initial render. This tells the browser to start downloading the font file immediately, before it even parses the CSS that references it.
<!-- Next.js automatically adds this to <head> -->
<link
rel="preload"
href="/_next/static/media/inter-latin.abc123.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
Without preloading, the browser discovers font files late — only after downloading HTML, parsing CSS, and encountering the @font-face rule. Preloading moves discovery to the very beginning of the page load.
3. size-adjust Eliminates Layout Shift
This is the most clever part. Next.js generates a @font-face declaration for the fallback font with size-adjust, ascent-override, descent-override, and line-gap-override values that make the fallback font occupy the exact same space as the custom font.
/* Generated by Next.js automatically */
@font-face {
font-family: '__Inter_Fallback';
src: local('Arial');
ascent-override: 90.49%;
descent-override: 22.56%;
line-gap-override: 0.00%;
size-adjust: 107.06%;
}
These override values adjust Arial's metrics to match Inter's metrics. When the custom font loads and swaps in, the text occupies the exact same bounding box. No reflow. No jump. Zero CLS.
+---------------------------------------------------------------+
| THE SIZE-ADJUST TECHNIQUE |
+---------------------------------------------------------------+
| |
| Before custom font loads: |
| +-----------------------------------+ |
| | Hello World (Arial, size-adjusted)| <-- 200px wide |
| +-----------------------------------+ |
| |
| After custom font loads: |
| +-----------------------------------+ |
| | Hello World (Inter) | <-- 200px wide |
| +-----------------------------------+ |
| |
| Same width. Same height. Same line spacing. |
| The swap is invisible to the user. |
| |
+---------------------------------------------------------------+
Font Subsetting — Serve Only What You Need
A full Inter font file with every language's characters (Latin, Cyrillic, Greek, Vietnamese) weighs over 300 KB. But if your site is in English, you only need the Latin subset — roughly 20-30 KB.
// Only load Latin characters — dramatically smaller file
const inter = Inter({
subsets: ['latin'],
});
// Load multiple subsets if your site supports multiple languages
const notoSans = Noto_Sans({
subsets: ['latin', 'cyrillic', 'greek'],
});
Next.js uses unicode-range splitting to serve subsets efficiently. The generated CSS includes multiple @font-face rules, each with a unicode-range declaration. The browser only downloads the subset files that contain characters actually used on the page.
/* Simplified example of what Next.js generates */
@font-face {
font-family: 'Inter';
src: url('inter-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153; /* Latin characters */
}
@font-face {
font-family: 'Inter';
src: url('inter-cyrillic.woff2') format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491; /* Cyrillic characters */
}
If a page contains only Latin text, the Cyrillic file is never downloaded. This is automatic — you don't write any conditional loading logic.
Common Mistakes
1. Loading fonts with a <link> tag in <head> instead of using next/font.
This creates external requests to Google's CDN, adds latency, causes layout shift, and sends user data (IP address, user agent) to Google. Always use next/font/google — it gives you the same fonts with zero network overhead and full privacy.
2. Not specifying subsets.
If you omit the subsets option, Next.js may download the entire font file including characters your site never uses. Always specify subsets: ['latin'] (or whichever subsets you need) to keep file sizes minimal.
3. Loading too many font families. Each font family adds download weight, even with variable fonts. Stick to two families maximum — one for body text, one for headings or code. Three or more families almost always indicates a design that needs simplification.
4. Using static font weights when a variable font is available.
If the font supports variable weights, passing weight: ['400', '500', '700'] forces Next.js to download three separate static files instead of one variable file. Omit the weight option for variable fonts and let Next.js use the variable version automatically.
5. Importing fonts in every component instead of the root layout.
Font initialization should happen once, in app/layout.tsx. If you import and initialize the same font in multiple components, you create duplicate @font-face declarations and potentially duplicate downloads. Initialize once at the root, share via CSS variables.
Interview Questions
1. "How does next/font eliminate layout shift caused by font loading?"
Next.js uses three techniques together. First, it self-hosts fonts by downloading them at build time and serving them from the same origin — eliminating the latency of external requests to Google's CDN. Second, it automatically adds <link rel="preload"> tags so the browser discovers font files at the very start of page loading, not after CSS parsing. Third — and most importantly — it generates @font-face fallback declarations with size-adjust, ascent-override, descent-override, and line-gap-override values that make the system fallback font occupy the exact same dimensions as the custom font. When the custom font swaps in, the text occupies the same bounding box, resulting in zero Cumulative Layout Shift.
2. "What is the difference between next/font/google and next/font/local? When would you use each?"
next/font/google loads fonts from the Google Fonts catalog. At build time, Next.js downloads the font files and bundles them with your application — no runtime request to Google. next/font/local loads font files from your project directory. You use next/font/google when the font you need is available in Google's collection (Inter, Roboto, etc.) because it requires zero file management. You use next/font/local when you have licensed or custom fonts that aren't on Google Fonts — brand typefaces, purchased fonts, or proprietary designs. Both produce the same result at runtime: self-hosted, preloaded fonts with zero layout shift.
3. "What are variable fonts and why are they better for performance?"
Variable fonts pack an entire weight range (e.g., 100 to 900) into a single file, as opposed to static fonts which require a separate file for each weight. A site using three weights with static fonts downloads three files totaling around 75 KB. The same site using a variable font downloads one file of around 30 KB — 60% smaller with two fewer HTTP requests. Variable fonts also unlock intermediate weights like 450 or 550 that don't exist as static files, giving designers more flexibility. Most popular Google Fonts (Inter, Roboto, Open Sans) are already variable fonts, and next/font uses variable versions automatically when available.
4. "Why does Next.js self-host fonts instead of using Google's CDN? Isn't a CDN faster?"
Counterintuitively, self-hosting is faster for fonts. Google's CDN requires a DNS lookup for fonts.googleapis.com, a connection, a CSS file download, then another DNS lookup for fonts.gstatic.com and another connection to download the actual font files. That's 4-6 round trips before the font arrives. Self-hosting serves font files from the same domain as your HTML — zero extra DNS lookups, zero extra connections. The browser downloads font files in parallel with your other static assets. Additionally, self-hosting eliminates the privacy concern of sending user IP addresses and user agents to Google on every page load, which matters for GDPR compliance.
5. "How does font subsetting work in Next.js, and why does it matter?"
Font subsetting means including only the character sets your site needs. A full Inter font covering Latin, Cyrillic, Greek, and Vietnamese characters is over 300 KB. The Latin-only subset is around 20-30 KB. In next/font, you specify subsets in the configuration: subsets: ['latin']. Next.js then generates multiple @font-face rules with unicode-range declarations, and the browser only downloads the subset files containing characters that actually appear on the page. This is especially important for internationally available fonts like Noto Sans, which support dozens of scripts. Without subsetting, users download hundreds of kilobytes of glyphs they will never see.
Quick Reference — Font Optimization Cheat Sheet
+---------------------------------------------------------------+
| NEXT/FONT CHEAT SHEET |
+---------------------------------------------------------------+
| |
| GOOGLE FONTS: |
| import { Inter } from 'next/font/google' |
| const inter = Inter({ subsets: ['latin'] }) |
| <html className={inter.className}> |
| |
| LOCAL FONTS: |
| import localFont from 'next/font/local' |
| const font = localFont({ src: './fonts/My.woff2' }) |
| <html className={font.className}> |
| |
| CSS VARIABLES: |
| const inter = Inter({ variable: '--font-sans' }) |
| <html className={inter.variable}> |
| body { font-family: var(--font-sans); } |
| |
| VARIABLE FONTS: |
| Omit weight option -> Next.js uses variable version |
| Single file, all weights 100-900 |
| |
+---------------------------------------------------------------+
+---------------------------------------------------------------+
| KEY RULES |
+---------------------------------------------------------------+
| |
| 1. Always specify subsets: ['latin'] |
| 2. Use CSS variables for multi-font setups |
| 3. Initialize fonts in root layout only |
| 4. Prefer variable fonts over static weights |
| 5. Max 2 font families per project |
| 6. next/font/google > <link> tag (always) |
| 7. display: 'swap' is the default and correct choice |
| |
+---------------------------------------------------------------+
| Feature | Traditional <link> | next/font |
|---|---|---|
| External requests | Yes (Google CDN) | No (self-hosted) |
| Layout shift (CLS) | Yes (no size matching) | Zero (size-adjust) |
| Privacy | Leaks IP to Google | No external requests |
| Preloading | Manual | Automatic |
| Subsetting | Manual unicode-range | Automatic with subsets |
| Build-time download | No (runtime) | Yes |
| Configuration | CSS/HTML | JavaScript (type-safe) |
Prev: Lesson 7.1 -- Image Optimization in Next.js Next: Lesson 7.3 -- Script Optimization in Next.js
This is Lesson 7.2 of the Next.js Interview Prep Course -- 8 chapters, 33 lessons.