TL;DR
Loading fonts from Google Fonts via <link> in <head> in 2026 is an automatic performance regression — extra DNS lookup, render-blocking CSS, and CLS when text "jumps" after weight 600 loads. next/font self-hosts files in the bundle, sets font-display: swap, and generates CSS variables — no request to fonts.googleapis.com. Subsetting to latin + latin-ext (Polish ą, ę, ł and German umlauts) cuts 60–80% of file size vs full unicode. Space reservation via size-adjust or fixed hero line-height prevents layout shift. Hosting on DevStudioIT Cloud serves fonts from the same origin as HTML — one TLS handshake, long-term cache after build.
Who is this for
- Corporate sites with custom Figma typography — dev must match without hurting LCP
- Next.js teams seeing CLS 0.15+ in Lighthouse despite optimized images
- Multilingual PL/DE projects with latin-ext — full charset without unused CJK glyphs
- Designers picking 4 font weights "because it looks good" — KB budget needs justification
- Anyone after a CWV audit where "font loading" appears in diagnostics
Keyword
next/font subsetting, web fonts performance, cls fonts nextjs, font-display swap, corporate site typography, google fonts self hosting 2026
The problem: external fonts and Core Web Vitals
Classic import:
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet">Request chain:
| Step | Delay | Impact |
|---|---|---|
| DNS fonts.googleapis.com | 20–80 ms | After HTML TTFB |
| CSS font-face | blocking or FOIT | Delayed first text |
| DNS fonts.gstatic.com | another RTT | Worse LCP when text is LCP |
| Download .woff2 400 + 600 + 700 | 150–400 KB | Main thread decode |
CLS happens when fallback (Arial) metrics differ from Inter — hero heading height changes 4–12 px after swap.
next/font — self-hosting in Next.js App Router
// app/fonts.ts
import { Inter } from 'next/font/google';
export const inter = Inter({
subsets: ['latin', 'latin-ext'],
weight: ['400', '600'],
display: 'swap',
variable: '--font-inter',
preload: true,
adjustFontFallback: true,
});Layout:
import { inter } from './fonts';
export default function RootLayout({ children }) {
return (
<html lang="en" className={inter.variable}>
<body className="font-sans antialiased">{children}</body>
</html>
);
}Tailwind (tailwind.config.ts):
theme: {
extend: {
fontFamily: {
sans: ['var(--font-inter)', 'system-ui', 'sans-serif'],
},
},
},| Option | Effect |
|---|---|
subsets: ['latin-ext'] |
Polish and German diacritics |
weight: ['400','600'] |
Only used weights — each weight = separate file |
adjustFontFallback: true |
Automatic size-adjust on fallback |
variable |
One CSS class, no scattered @font-face |
Subsetting — what to include, what to skip
| Subset | When | Approx Inter 400 size |
|---|---|---|
latin |
EN-only landing | ~15 KB woff2 |
latin-ext |
PL, DE, CS, RO | +8–12 KB |
cyrillic |
UA/RU market | +20 KB — only if locale |
| Full unicode | ❌ avoid | 200 KB+ |
For DevStudio.it (pl/en/de) ['latin', 'latin-ext'] is enough. Do not import vietnamese "just in case".
Licensed brand font (OTF):
import localFont from 'next/font/local';
export const brandSans = localFont({
src: [
{ path: '../public/fonts/Brand-Regular.woff2', weight: '400' },
{ path: '../public/fonts/Brand-SemiBold.woff2', weight: '600' },
],
display: 'swap',
variable: '--font-brand',
});Convert OTF → woff2: fonttools or CI pipeline before commit.
CLS — space reservation beyond next/font
adjustFontFallback helps, but hero with large text-5xl and custom line-height can still shift:
.hero-title {
font-size: clamp(2rem, 5vw, 3.5rem);
line-height: 1.15;
min-height: 2.3em; /* reserve for 2 lines */
}| Technique | CLS | Notes |
|---|---|---|
font-display: optional |
Lowest | Risk of no custom font on slow network |
swap + size-adjust |
Low | B2B recommendation |
| Preload only critical weight | Better LCP | preload: true in next/font by default |
| System font stack on mobile | Zero CLS | Brand vs metrics tradeoff |
Test mobile 4G throttling in Lighthouse — desktop hides the problem.
Typography and LCP — when LCP is text
On pages with minimal hero the LCP element may be H1 heading, not an image. Then:
- Preload only weight used in H1 (400 or 600)
- Avoid
@importfonts in CSS modules — loads late - Server Component for hero — HTML with font in first stream bytes
Font should not live in a lazy-loaded Client Component — that delays text LCP.
Multiple fonts — heading + body without KB explosion
| Pattern | woff2 files | Recommendation |
|---|---|---|
| 1 family, 2 weights | 2 × ~20 KB | ✅ default choice |
| Body + display (2 families) | 4 files | OK if display only H1–H2 |
| 4 weights + 2 families | 8+ files | Review in performance budget |
Display font on headings via next/font with preload: false if H1 is below fold on mobile — controversial; better to shrink display subset.
Monitoring after deploy on DevStudioIT Cloud
After deploy compare:
| Source | Font-related metric |
|---|---|
| Lighthouse CI | CLS, LCP, "Font display" audit |
| CrUX / Search Console | Field CLS after 28 days |
| Web Vitals RUM | layout-shift attributions |
Regression: designer added weight 700 and italic "for quotes" — bundle +40 KB, CLS +0.05. Performance budget in CI should catch via overall score; assert CLS directly.
Design → dev handoff — Figma typography without surprises
Before the developer implements fonts from mockups, agree on a decision table:
| Figma element | Next.js implementation | Question for design |
|---|---|---|
| Inter 400 body | next/font weight 400 |
Is it enough? |
| Inter 600 headings | weight 600 | Is 700 necessary? |
| Inter 700 CTA | Separate woff2 file | Is 600 enough on button? |
| Italic quotes | +italic file | System <em> instead? |
Figma design tokens should map 1:1 to Tailwind fontFamily — avoid "Satoshi in Figma, Arial in production because of licensing." If brand font needs web license, woff2 in repo before sprint start, not on go-live eve.
Transfer Figma line-height (e.g. 120%) to CSS with min-height reserve for multi-line H1 — Figma does not measure CLS.
Typography and accessibility — not just KB
| Requirement | Minimum | Font impact |
|---|---|---|
| WCAG AA contrast | 4.5:1 body, 3:1 large text | Avoid weight 300 on light background |
prefers-reduced-motion |
No text animation | Do not animate font-weight |
| 200% zoom | Readable without horizontal scroll | rem instead of fixed px on font-size |
Custom font does not excuse low contrast — pick weight 500/600 instead of 300 on gray background.
FAQ
next/font google vs self-host files in public/?
next/font/google downloads at build and self-hosts — equivalent to manual public/, but with automatic hash in filename and zero CORS config. Manual public/ only for non-Google fonts (brand OTF).
Are variable fonts (.woff2 variable) always smaller?
Often yes with 3+ weights — one file instead of three. Not every family has a well-hinted variable version; test size and render on Windows.
Third-party CDN fonts (Adobe Fonts, Fontshare)?
Each external origin means separate DNS + cache. For CWV self-host after licensing. Adobe Fonts on marketing pages with strict LCP budget — risky.
Inter vs system-ui — when to drop custom?
Ads landing with LCP > 3 s on mobile — test variant with system-ui. If conversion is unchanged, keep system stack.
Want typography without CLS regression?
- Contact us — we pick font stack, next/font, and Lighthouse CI thresholds
- Performance budget CWV — enforce CLS in PRs
- Next.js image optimization — when LCP is hero image, not font
About the author
We build fast websites, web/mobile apps, AI chatbots and hosting setups — with a focus on SEO and conversion.
Recommended links
From theory to production — Branchly, our hosting stack and shipped work.
