Font Loading in 2026: Why next/font Isn't Always the Answer
next/font is great until it isn't. A practical breakdown of when to use it, when to ship raw @font-face, and how to stop fonts from wrecking your LCP and CLS scores.

Fonts are still the easiest way to wreck a Lighthouse score in 2026. next/font solved a lot of the old pain — no more flashing Times New Roman, no more layout shift on the hero — but treating it as a default for every project has bitten us more than once. This is what we've learned shipping it across a dozen production apps.
What next/font actually does
Before deciding whether to use it, it's worth being precise about what it does, because the marketing copy blurs a few things together.
At build time, next/font does three things:
- Downloads the font files from Google (or reads your local files) and self-hosts them from your own origin.
- Generates a CSS
@font-facedeclaration withfont-display: swapby default, plus asize-adjustfallback metric calculated from the font's actual ascent/descent values. - Hashes the file and sets
Cache-Control: public, max-age=31536000, immutable.
The size-adjust trick is the bit most people don't realise they're getting. It picks a system fallback (Arial, Times, etc.), measures the metrics of your real font, and writes a CSS @font-face for the fallback that pads or trims it to match. That's how CLS stays near zero during the swap.
import { Inter } from 'next/font/google';
export const inter = Inter({
subsets: ['latin'],
display: 'swap',
variable: '--font-inter',
adjustFontFallback: 'Arial', // explicit is better
});
Use it in the root layout:
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={inter.variable}>
<body className="font-sans">{children}</body>
</html>
);
}
For 80% of projects, stop here. It's fine. The rest of this post is about the other 20%.
Where it falls down
1. Multiple weights on a non-variable font
If you import a static (non-variable) family and ask for four weights plus italics, you've just queued eight font files. next/font will preload one of them automatically — usually the first one you reference — and the others load on demand. That's mostly fine, except when a heading uses weight 700 and it's above the fold. You get a swap, a FOUT, and a real LCP regression on the hero text.
Fix: use a variable font when one exists. One file, all weights. Inter, Geist, Roboto Flex, Source Sans 3, Recursive — all variable. Check the foundry before assuming.
2. Preload bloat with multiple families
next/font preloads every font you call at the top level of a layout or page. Import three families in your root layout and you'll see three <link rel="preload"> tags in the document head, fighting for connection priority with your LCP image.
We've seen this drop LCP by 200–400ms on slower connections in our own builds. The fix is to turn off automatic preload on the secondary families:
export const playfair = Playfair_Display({
subsets: ['latin'],
variable: '--font-display',
preload: false, // only loaded when CSS that uses it is parsed
});
The browser will still fetch it when it encounters a rule that needs it. You just stop racing against the LCP image.
3. Google Fonts at build time isn't free
next/font/google fetches the font files during next build. On a clean CI runner with no network cache, that adds 5–20 seconds per family. More importantly, it can fail — Google's font CDN has had brownouts, and your build will hard-error. We've had this take down a deploy at 2am.
Mitigations:
- Vendor the font files into the repo and use
next/font/localinstead. - Or cache the
.next/cache/fontsdirectory between CI runs (Vercel does this automatically; other runners don't).
4. The className vs variable confusion
next/font returns both a className and a variable. They are not interchangeable, and using the wrong one is the single most common mistake we see in code review.
font.classNameapplies the font directly to whatever element you put it on. Good for simple cases.font.variableonly declares a CSS custom property (--font-inter: 'Inter', ...). You still need a stylesheet that referencesvar(--font-inter).
If you're using Tailwind or a design system with tokens, variable is what you want. If you're not, className is fine. Mixing them silently in different components means some text gets the font and some falls back to system — and that's usually only spotted in production.
When to use next/font/local instead
Reach for local when:
- You have a licensed font from Monotype, Linotype, or a smaller foundry. Don't upload these to a public CDN; the licence almost always forbids it.
- You need a specific subset (e.g. Vietnamese plus Latin Extended) that Google doesn't offer cleanly.
- You want deterministic builds with zero network dependency.
- You're using a variable font with a custom axis (optical size, grade, slant) and need the WOFF2 you've subset yourself.
import localFont from 'next/font/local';
export const brand = localFont({
src: [
{
path: './fonts/brand-variable.woff2',
style: 'normal',
weight: '100 900',
},
{
path: './fonts/brand-variable-italic.woff2',
style: 'italic',
weight: '100 900',
},
],
variable: '--font-brand',
display: 'swap',
declarations: [
{ prop: 'font-feature-settings', value: '"ss01", "cv11"' },
],
});
The declarations field is underused. It lets you bake stylistic sets, ligatures, or tabular numerals into the @font-face itself, so every consumer of that font gets them without remembering to add font-feature-settings to their CSS.
When to skip next/font entirely
There's a small set of cases where rolling your own @font-face is better:
- You're loading a font from a foundry's licensed CDN. Adobe Fonts, Monotype, and Hoefler all require their script or their CDN URL.
next/fontcan't help you here. - You need conditional fonts. A locale-specific Arabic font that only loads on
/ar/*routes, for example. Putting it in the root layout means English visitors pay for it too. - You're using a design system shipped as a package that already includes its own
@font-facerules.
For those, write the CSS yourself and be deliberate:
@font-face {
font-family: 'Söhne';
src: url('/fonts/sohne-var.woff2') format('woff2-variations');
font-weight: 100 900;
font-display: optional;
font-style: normal;
size-adjust: 101%;
ascent-override: 92%;
descent-override: 24%;
line-gap-override: 0%;
}
A note on font-display: optional
swap is the default and it's what next/font uses. It's safe but it guarantees a flash. optional tells the browser: if the font isn't ready within ~100ms, don't bother for this page load. It eliminates FOUT entirely at the cost of some first-visit users seeing the fallback. On content-heavy sites where the brand font is decorative, optional is often the right call. On product UI where typography is the brand, stick with swap.
Measuring it properly
Lighthouse will tell you about render-blocking fonts, but it doesn't catch the subtler problems. We instrument production with the Web Vitals library and tag font-related metrics:
import { onLCP, onCLS } from 'web-vitals/attribution';
onLCP((metric) => {
const element = metric.attribution.element;
const isText = element && !element.includes('img');
beacon({ name: 'LCP', value: metric.value, textLCP: isText });
});
onCLS((metric) => {
const sources = metric.attribution.largestShiftSource;
beacon({ name: 'CLS', value: metric.value, source: sources });
});
If your LCP element is text and your p75 LCP is above 2.5s, fonts are usually involved. If CLS spikes are coming from text nodes shifting on font load, your fallback metrics are wrong — check adjustFontFallback or your manual size-adjust values.
Where we'd start
If you're auditing an existing Next.js app today, do this in order:
- Open DevTools, network tab, filter to font. Count the requests on a cold load. If it's more than two, that's your first fix.
- Check the document head for
<link rel="preload" as="font">tags. Anything beyond your primary family should havepreload: false. - Swap static families for variable ones wherever the foundry offers it.
- For non-critical decorative fonts, try
font-display: optionalbehind a feature flag and watch CLS. - If your build is flaky, vendor the WOFF2 files locally and switch to
next/font/local.
next/font is a good default. It's not a strategy. Treat font loading as a Core Web Vitals concern with the same rigour you'd apply to images, and the rest follows. If you'd like a hand auditing yours, our web development team does this kind of work routinely.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading

View Transitions in the App Router: The Good, the Janky, and the Layout Shifts
Cross-document view transitions finally landed in stable browsers and Next.js shipped a primitive for them. Here's what actually works in production, what still tears, and how to keep CLS honest.

Middleware Is Not a Router: What We Learned Rebuilding Auth on the Edge
Next.js middleware looks like the perfect place for auth. Then your bundle balloons, your Edge runtime chokes on a Node API, and your login redirects loop. Here's how we untangled it.

Cache Tags in Next.js: How We Stopped Nuking the Entire CDN on Every Publish
A war story about how `revalidateTag` and a disciplined tagging scheme replaced our nightly full-cache purge — and the four gotchas we hit getting there in production.
