All articles
Web DevelopmentAugust 25, 2026 6 min read

Font Loading in Next.js 15: The CLS Regression Nobody Talks About

next/font is supposed to make font loading boring. In three recent audits it was the single biggest CLS contributor. Here's what's actually happening and how to fix it without ripping out the whole pipeline.

next/font is one of those APIs everyone assumes is a solved problem. You import it, you spread the className, you move on. On the last three App Router audits we ran, it was the single largest contributor to Cumulative Layout Shift — and none of the teams had touched their font config in months.

This is the story of a regression that hides in plain sight, why the default behaviour changed in ways most upgrade guides skipped, and the specific fallback metrics that actually put CLS back where it belongs.

The symptom: CLS creeps back after a clean upgrade

The pattern was consistent across all three projects. Teams upgraded from an older Next.js minor to 15.x, ran Lighthouse, and saw CLS drift from the 0.02 – 0.05 range into 0.15 – 0.25 territory on marketing and blog routes. LCP was fine. INP was fine. Only CLS moved.

The interesting part: nothing about the font setup had changed. Same next/font/google import, same variable, same display: 'swap'. What had changed was:

  • The rendered font weight distribution on the page (design system tokens got refactored)
  • The way headings were composed (more <Balancer>-style client components)
  • The fallback font stack, which someone "cleaned up" during a Tailwind config sweep

Individually, none of these looked like a font problem. Together, they broke the invisible contract next/font relies on to prevent layout shift.

What next/font is actually doing under the hood

When you use next/font, Next.js does three things at build time:

  1. Downloads the font files and self-hosts them (killing the third-party request to Google's CDN).
  2. Generates a @font-face declaration with font-display: swap by default.
  3. Computes a fallback font override using size-adjust, ascent-override, descent-override, and line-gap-override so the fallback font occupies roughly the same box as the real font.

That third step is the magic. Without it, the browser paints text in Arial or system-ui, then swaps to Inter, and every line height shifts by a few pixels. With it, the swap is nearly invisible.

The problem is that the override is computed against one specific fallback font. If you override the fallback stack, or if the browser picks a different fallback than Next.js assumed, the math is wrong and CLS returns.

Where the regression actually lives

Here's a config that looks fine and is quietly broken:

// app/fonts.ts
import { Inter } from 'next/font/google';

export const inter = Inter({
  subsets: ['latin'],
  display: 'swap',
  variable: '--font-inter',
});

And the Tailwind config:

// tailwind.config.ts
export default {
  theme: {
    fontFamily: {
      sans: ['var(--font-inter)', 'system-ui', 'sans-serif'],
    },
  },
};

Spot the problem? next/font generates its size-adjust math against a specific adjusted fallback (it injects something like 'Inter Fallback' into the stack). By hardcoding system-ui as the immediate next entry in Tailwind, you've bypassed the adjusted fallback entirely. The browser renders system-ui during the swap period with no metric overrides, then swaps to Inter — and every heading jumps.

The fix is to let next/font inject the adjusted fallback where it wants to:

import { Inter } from 'next/font/google';

export const inter = Inter({
  subsets: ['latin'],
  display: 'swap',
  variable: '--font-inter',
  fallback: ['system-ui', 'sans-serif'],
  adjustFontFallback: 'Arial', // explicit — don't let this drift
});

And in Tailwind, reference only the variable:

fontFamily: {
  sans: ['var(--font-inter)'],
},

next/font will produce a font stack like var(--font-inter), 'Inter Fallback', system-ui, sans-serif, where 'Inter Fallback' is the synthetic family with the metric overrides applied. That's the one that matters.

The variable font trap

Variable fonts make this worse. If you import Inter as a variable font and use weights 400 through 800 across the page, next/font still computes fallback metrics against a single reference weight. Bold headings shift more than body text because the weight-adjusted glyph widths diverge further from the fallback's fixed weight.

We've had good results splitting into two font instances when the design leans heavily on both extremes:

export const interBody = Inter({
  subsets: ['latin'],
  weight: ['400', '500'],
  variable: '--font-inter-body',
  display: 'swap',
});

export const interDisplay = Inter({
  subsets: ['latin'],
  weight: ['700', '800'],
  variable: '--font-inter-display',
  display: 'swap',
});

Yes, it's two @font-face declarations and two subtly different fallback metric blocks. That's the point — headings get a fallback tuned for heavy weights, body gets one tuned for regular. In our experience this shaves CLS on hero-heavy landing pages by a noticeable margin, though the exact win depends entirely on how much your layout leans on tight heading rhythm.

Measuring it properly

Lighthouse in the CLI is fine for a smoke test, but CLS is a field metric. The lab number and the real-user number diverge constantly, especially on fonts, because the swap period depends on the user's network and cache state.

What we actually watch:

  • CrUX / real user data segmented by route type (marketing vs app shell vs blog).
  • Long-tail CLS, not the median. If your p75 is 0.05 but your p95 is 0.4, you have a font problem on a subset of devices — probably ones without the font cached.
  • Layout shift attribution via the PerformanceObserver API in a small client script, logging the shift source nodes.

A minimal attribution snippet:

'use client';
import { useEffect } from 'react';

export function CLSAttribution() {
  useEffect(() => {
    const observer = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        const shift = entry as PerformanceEntry & {
          value: number;
          sources?: Array<{ node?: Node }>;
          hadRecentInput?: boolean;
        };
        if (shift.hadRecentInput || shift.value < 0.01) continue;
        const nodes = shift.sources?.map((s) =>
          s.node instanceof Element ? s.node.tagName : 'unknown'
        );
        // ship to your analytics of choice
        console.debug('[cls]', shift.value, nodes);
      }
    });
    observer.observe({ type: 'layout-shift', buffered: true });
    return () => observer.disconnect();
  }, []);
  return null;
}

Drop that into your root layout for a week. If most shifts point at H1, H2, or P, it's fonts. If they point at IMG or IFRAME, it's a different fight.

The three fixes that actually moved the needle

Across the audits, three changes consistently produced the biggest CLS improvements:

1. Reserve heading height with min-height on hero copy

Headings using text-balance or client-side balancing libraries re-flow after hydration. Even with perfect font metrics, the balancing pass itself shifts layout. Reserve the space:

<h1 className="min-h-[6.5rem] text-5xl font-bold text-balance">
  {title}
</h1>

Ugly? A little. But min-height sized to the expected two-line rendering removes the shift entirely.

2. Preload only the fonts above the fold

next/font preloads by default when a font is used in a route segment. If you import fonts in a shared layout but only use display weight on the homepage hero, everything gets preloaded on every route. Scope your imports to where the font is actually used, or pass preload: false for weights that appear below the fold.

3. Stop importing icon fonts through next/font

We've seen teams pipe icon fonts (Material Symbols, Font Awesome) through next/font/google. Icon fonts don't have meaningful fallback metrics, so the size-adjust math is nonsense, and they render as boxes during the swap. Use SVG icons or a component library. This isn't a font-loading problem; it's a font-choice problem.

Where we'd start

If you're upgrading to Next.js 15 or auditing an App Router project this quarter:

  1. Grep your Tailwind and CSS for hardcoded fallback stacks (system-ui, -apple-system, Arial). If any of them come before the next/font variable, you're likely leaking CLS.
  2. Add the attribution snippet above and let it collect field data for a week — median CLS lies.
  3. Split display and body font instances if your headings run 700+ weight and your body runs 400.
  4. Reserve vertical space on any heading that wraps to two lines above the fold.

None of this is glamorous work. But CLS is the Core Web Vital that regresses most quietly, and font pipelines are where it hides. If you want a second pair of eyes on a Next.js performance audit, our web development team does this kind of teardown regularly — and it's almost always the fonts.

#Next.js#Performance#Core Web Vitals#React#Typography

Want a team like ours?

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

Start a project