next/font
The next/font module downloads font files at build time, self-hosts them, and injects font-display: swap — eliminating the external network request and preventing layout shift.
Global Styles & Fonts with next/font
Typography is one of the first things users notice about a web app — and one of the
most common sources of subtle performance problems. Loading fonts from Google Fonts
the traditional way (<link href="https://fonts.googleapis.com/...">)
adds an extra network request, blocks rendering, and causes layout shift as the font
swaps in. On a slow connection this can make your perfectly optimised Next.js app
feel sluggish.
next/font solves all of this. It downloads fonts at build time,
self-hosts them alongside your app, eliminates the external network request, and
guarantees zero layout shift. This lesson covers Google Fonts, local fonts, CSS
variables, multiple fonts, and the global styles patterns that tie everything together.
1. Why next/font Exists
Here's what happens when you load a Google Font the traditional way:
- Browser requests your HTML
- Browser parses HTML, finds the Google Fonts
<link>tag - Browser makes a second request to
fonts.googleapis.com - Google returns a CSS file with a
@font-facedeclaration pointing tofonts.gstatic.com - Browser makes a third request to download the actual font file
- Font loads — text re-renders with the new font (layout shift)
That's two extra network round trips and a layout shift that tanks your CLS (Cumulative Layout Shift) Core Web Vitals score.
Here's what next/font does instead:
- At build time, Next.js downloads the font files from Google Fonts
- Stores them in your
.nextoutput alongside your JavaScript - Generates a
@font-facerule pointing to your own domain - Injects
font-display: optionalorswapto prevent layout shift
The result: fonts load from your own server, zero external requests, zero layout shift, and your CLS score stays at zero.
2. Loading a Google Font
Import the font you want from next/font/google. Every Google Font
is available as a named export:
// app/layout.tsx
import { Inter } from "next/font/google";
// Configure the font
const inter = Inter({
subsets: ["latin"], // Only download the character subsets you need
weight: ["400", "500", "600", "700"], // Only download the weights you use
display: "swap", // Show fallback font until custom font loads
});
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={inter.className}>
{children}
</body>
</html>
);
}
inter.className is a string like "__Inter_abc123" that
Next.js generates at build time. Applying it to <body> makes
Inter the default font for your entire app via CSS inheritance.
Variable Fonts — The Recommended Approach
If the font supports variable font format (most modern Google Fonts do), you can
omit the weight array entirely — a variable font contains all weights
in a single file, which is smaller and more flexible:
// app/layout.tsx
import { Inter } from "next/font/google";
// No weight array needed — variable font handles all weights automatically
const inter = Inter({
subsets: ["latin"],
display: "swap",
});
With a variable font loaded, you can use any font weight in CSS (font-weight: 350,
font-weight: 720) and it just works — no extra downloads needed.
3. Using Multiple Fonts
Most designs use two fonts — one for body text and one for headings or display text. The cleanest way to handle this is with CSS variables so both fonts are available throughout your app:
// app/layout.tsx
import { Inter, Playfair_Display } from "next/font/google";
// Body font — clean, readable sans-serif
const inter = Inter({
subsets: ["latin"],
display: "swap",
variable: "--font-sans", // ← Expose as a CSS variable
});
// Display font — elegant serif for headings
const playfair = Playfair_Display({
subsets: ["latin"],
display: "swap",
weight: ["400", "700", "900"],
variable: "--font-display", // ← Expose as a CSS variable
});
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
{/*
Apply both font variables to body.
inter.variable adds --font-sans to the element.
playfair.variable adds --font-display to the element.
Then font-sans applies Inter as the default body font.
*/}
<body className={`${inter.variable} ${playfair.variable} font-sans`}>
{children}
</body>
</html>
);
}
Now both CSS variables are available anywhere in your CSS or Tailwind classes:
/* Using in CSS Modules */
.heading {
font-family: var(--font-display);
font-size: 3rem;
font-weight: 700;
}
.body {
font-family: var(--font-sans);
}
/* Using in Tailwind — after configuring @theme */
/* app/globals.css */
@import "tailwindcss";
@theme {
--font-sans: var(--font-sans); /* Maps Tailwind's font-sans to next/font variable */
--font-display: var(--font-display);
}
// Now use Tailwind's font utilities
<h1 className="font-display text-5xl font-bold">Big Heading</h1>
<p className="font-sans text-base">Body text paragraph</p>
4. Popular Google Font Combinations
Here are some well-tested font pairings commonly used in Next.js projects:
| Pairing | Body | Headings | Best for |
|---|---|---|---|
| Clean SaaS | Inter | Inter (same font, heavier weight) | Dashboards, apps, B2B |
| Editorial | Source Serif 4 | Playfair Display | Blogs, magazines, content sites |
| Modern agency | DM Sans | Fraunces | Portfolios, agencies, creative |
| Technical | Geist | Geist (same font) | Developer tools, docs, code |
| Warm startup | Plus Jakarta Sans | Plus Jakarta Sans | Startups, landing pages |
// Example: Modern agency pairing
import { DM_Sans, Fraunces } from "next/font/google";
const dmSans = DM_Sans({
subsets: ["latin"],
display: "swap",
variable: "--font-sans",
});
const fraunces = Fraunces({
subsets: ["latin"],
display: "swap",
variable: "--font-display",
axes: ["SOFT", "WONK"], // Variable font axes for extra control
});
5. Loading Local Fonts
For custom or paid fonts (not available on Google Fonts), use
next/font/local. Put the font files in your public/fonts/
directory and reference them:
// app/layout.tsx
import localFont from "next/font/local";
const geist = localFont({
src: [
{
path: "../public/fonts/GeistVF.woff2",
weight: "100 900", // Variable font — range of weights
style: "normal",
},
{
path: "../public/fonts/GeistMonoVF.woff2",
weight: "100 900",
style: "normal",
},
],
variable: "--font-geist",
display: "swap",
});
For non-variable fonts with separate files per weight:
// app/layout.tsx
import localFont from "next/font/local";
const myFont = localFont({
src: [
{ path: "../public/fonts/MyFont-Regular.woff2", weight: "400", style: "normal" },
{ path: "../public/fonts/MyFont-Medium.woff2", weight: "500", style: "normal" },
{ path: "../public/fonts/MyFont-SemiBold.woff2", weight: "600", style: "normal" },
{ path: "../public/fonts/MyFont-Bold.woff2", weight: "700", style: "normal" },
{ path: "../public/fonts/MyFont-Italic.woff2", weight: "400", style: "italic" },
{ path: "../public/fonts/MyFont-BoldItalic.woff2", weight: "700", style: "italic" },
],
variable: "--font-body",
display: "swap",
});
Font File Formats
Always use .woff2 — it has the best compression and is supported by
every modern browser. If you receive font files in other formats, convert them
to .woff2 using a tool like CloudConvert
or fonttools. Serving .ttf or .otf directly
bloats your page weight significantly.
6. A Complete globals.css
Here's a production-ready globals.css that combines Tailwind,
custom font variables, a CSS reset, design tokens, and base typography:
/* app/globals.css */
@import "tailwindcss";
/* ─── Design Tokens ──────────────────────────────────────────────────────── */
@theme {
/* Fonts — set by next/font via CSS variables on <body> */
--font-sans: var(--font-inter);
--font-display: var(--font-playfair);
--font-mono: var(--font-geist-mono);
/* Brand colours */
--color-brand-50: #eff6ff;
--color-brand-100: #dbeafe;
--color-brand-500: #3b82f6;
--color-brand-600: #2563eb;
--color-brand-700: #1d4ed8;
--color-brand-900: #1e3a8a;
/* Semantic colours */
--color-success: #10b981;
--color-warning: #f59e0b;
--color-danger: #ef4444;
/* Spacing extras */
--spacing-18: 4.5rem;
/* Border radius extras */
--radius-4xl: 2rem;
}
/* ─── CSS Reset ──────────────────────────────────────────────────────────── */
*, *::before, *::after {
box-sizing: border-box;
}
/* ─── Base Styles ─────────────────────────────────────────────────────────── */
html {
scroll-behavior: smooth;
text-size-adjust: 100%;
}
body {
color: theme(colors.gray.900);
background-color: theme(colors.white);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* ─── Focus Styles ────────────────────────────────────────────────────────── */
/* Remove default focus outline and use a consistent custom one */
:focus-visible {
outline: 2px solid theme(colors.blue.500);
outline-offset: 2px;
}
/* ─── Typography ──────────────────────────────────────────────────────────── */
/* Sensible defaults for rendered HTML content (blog posts, CMS content) */
.prose h1 { font-family: var(--font-display); font-size: 2.25rem; font-weight: 700; line-height: 1.2; margin-bottom: 0.5em; }
.prose h2 { font-family: var(--font-display); font-size: 1.875rem; font-weight: 700; line-height: 1.3; margin-top: 1.5em; margin-bottom: 0.5em; }
.prose h3 { font-size: 1.5rem; font-weight: 600; line-height: 1.4; margin-top: 1.25em; margin-bottom: 0.4em; }
.prose p { line-height: 1.75; margin-bottom: 1.25em; }
.prose a { color: theme(colors.blue.600); text-decoration: underline; }
.prose a:hover { color: theme(colors.blue.800); }
.prose ul { list-style: disc; padding-left: 1.5em; margin-bottom: 1.25em; }
.prose ol { list-style: decimal; padding-left: 1.5em; margin-bottom: 1.25em; }
.prose li { margin-bottom: 0.375em; line-height: 1.7; }
.prose code {
font-family: var(--font-mono);
font-size: 0.875em;
background: theme(colors.gray.100);
padding: 0.125em 0.375em;
border-radius: 4px;
}
.prose pre {
background: theme(colors.gray.900);
color: theme(colors.gray.100);
padding: 1rem 1.25rem;
border-radius: 8px;
overflow-x: auto;
margin-bottom: 1.25em;
}
.prose pre code {
background: transparent;
padding: 0;
font-size: 0.875rem;
color: inherit;
}
.prose blockquote {
border-left: 4px solid theme(colors.gray.200);
padding-left: 1rem;
font-style: italic;
color: theme(colors.gray.600);
margin: 1.5em 0;
}
.prose img {
border-radius: 8px;
margin: 1.5em 0;
}
/* ─── Accessibility ───────────────────────────────────────────────────────── */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
/* Reduce motion for users who prefer it */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
7. Adding a Monospace Font for Code Blocks
If your app displays code, a dedicated monospace font dramatically improves readability. Geist Mono (Vercel's font) and Fira Code are popular choices:
// app/layout.tsx
import { Inter, Fira_Code } from "next/font/google";
const inter = Inter({
subsets: ["latin"],
variable: "--font-sans",
display: "swap",
});
const firaCode = Fira_Code({
subsets: ["latin"],
variable: "--font-mono",
display: "swap",
weight: ["400", "500"],
});
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body className={`${inter.variable} ${firaCode.variable} font-sans`}>
{children}
</body>
</html>
);
}
// Now use it in CSS or Tailwind
<code className="font-mono text-sm bg-gray-100 px-1.5 py-0.5 rounded">
npm install next
</code>
8. Preloading and Font Subsets
Next.js preloads fonts automatically — it adds a <link rel="preload">
tag to the HTML <head> so the font starts downloading before the
CSS is even parsed. This means fonts are available almost immediately.
The subsets option controls which character ranges to download.
Always specify only the subsets you actually use:
// For English-only sites
const font = Inter({ subsets: ["latin"] });
// For sites with extended Latin characters (French, German, Polish, etc.)
const font = Inter({ subsets: ["latin", "latin-ext"] });
// For sites with Cyrillic characters (Russian, Bulgarian, etc.)
const font = Inter({ subsets: ["latin", "cyrillic"] });
// For sites with Greek characters
const font = Inter({ subsets: ["latin", "greek"] });
Downloading unnecessary subsets bloats your font files. The latin
subset covers all standard English and Western European characters — it's the
right choice for the vast majority of English-language apps.
9. font-display Options
The display option controls what the browser shows while the custom
font is loading:
| Value | Behaviour | Layout Shift? | Best for |
|---|---|---|---|
"optional" |
Use custom font only if it loads within ~100ms, otherwise use fallback forever | None | Best CLS score — perfect for most apps |
"swap" |
Show fallback immediately, swap to custom font when loaded | Yes (brief) | When brand font is critical to identity |
"block" |
Brief invisible text, then custom font | No | Rarely — causes invisible text briefly |
"fallback" |
Short block period then swap, timeout to fallback | Minimal | Balance between swap and optional |
For most apps, "swap" is the safest default — users always see text
immediately, and the font swap is barely noticeable because next/font
also adjusts the fallback font's metrics to match the custom font's size and
line height, minimising any shift.
10. Common Gotchas
-
Defining fonts outside the module scope. Font functions must be
called at the module level — not inside a component function or a
useEffect. Next.js processes them at build time, so they must be statically analysable:
// ❌ Inside a component — doesn't work
export default function Layout({ children }) {
const inter = Inter({ subsets: ["latin"] }); // Error!
return <body className={inter.className}>{children}</body>;
}
// ✅ At module level — correct
const inter = Inter({ subsets: ["latin"] });
export default function Layout({ children }) {
return <body className={inter.className}>{children}</body>;
}
-
Forgetting to apply the variable to the HTML element.
If you use
variable: "--font-sans"but don't addinter.variableto the<body>or<html>tag, the CSS variable is never defined in the DOM and your font won't load. - Loading too many font weights. Each weight is a separate file download. Only include the weights you actually use in your design — most apps need regular (400), medium (500), semibold (600), and bold (700). Loading 100 through 900 just to have them available bloats your page.
-
Using Google Fonts CDN alongside next/font. Don't import the
same font both via next/font and a Google Fonts
<link>tag — you'll download it twice. Pick one approach and stick to it. -
Not converting local font files to woff2. If your designer
hands you
.ttfor.otffiles, convert them to.woff2before adding them to your project. The size difference is significant — a 200KB TTF becomes ~60KB as woff2.
Key Takeaways
next/fontdownloads fonts at build time, self-hosts them, and eliminates external font requests and layout shift.- Import Google Fonts from
next/font/google— call the font function at module level and apply.classNameor.variableto your root layout. - Use
variable: "--font-name"to expose a font as a CSS custom property — essential for multiple fonts and Tailwind integration. - Use
next/font/localfor custom or paid fonts — always provide.woff2files. - Specify only the
subsetsandweightvalues you actually use to minimise download size. - Variable fonts are preferred — one file covers all weights.
display: "swap"is the safest default — users always see text, and next/font adjusts fallback metrics to minimise shift.- Define fonts at module level, not inside component functions.
Next up: Lesson 304 — Dark Mode with Tailwind & next-themes. You'll implement flicker-free dark mode with system preference detection, a user-controlled toggle, and localStorage persistence — all without a white flash on page load.