All articles
Web DevelopmentJune 22, 2026 6 min read

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.

Font Loading in 2026: Why next/font Isn't Always the Answer

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:

  1. Downloads the font files from Google (or reads your local files) and self-hosts them from your own origin.
  2. Generates a CSS @font-face declaration with font-display: swap by default, plus a size-adjust fallback metric calculated from the font's actual ascent/descent values.
  3. 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/local instead.
  • Or cache the .next/cache/fonts directory 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.className applies the font directly to whatever element you put it on. Good for simple cases.
  • font.variable only declares a CSS custom property (--font-inter: 'Inter', ...). You still need a stylesheet that references var(--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/font can'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-face rules.

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:

  1. 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.
  2. Check the document head for <link rel="preload" as="font"> tags. Anything beyond your primary family should have preload: false.
  3. Swap static families for variable ones wherever the foundry offers it.
  4. For non-critical decorative fonts, try font-display: optional behind a feature flag and watch CLS.
  5. 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.

#Next.js#Performance#Web Vitals#Typography#App Router

Want a team like ours?

72Technologies builds production software for the kind of teams who actually read this blog.

Start a project