next/font CLS Layout Shift in Next.js 16: Real Fix

next/font causing CLS layout shift in Next.js 16 after the upgrade? Fix font fallback metrics, size-adjust, and display swap settings with working code.
PerformanceCore Web VitalsNext.js
April 23, 20266 min read1023 words

The Problem

Ran a Lighthouse audit on a client site Tuesday morning and CLS had jumped from 0.02 to 0.18. Nothing in the build had changed except the Next.js version (we bumped from 15.4 to 16.2 the previous night). PageSpeed Insights flagged "Avoid large layout shifts" and pointed directly at the main h1 and body text. Same fonts, same CSS, same images. The hero just jerked a pixel or two on every fresh load.

If you are seeing a CLS regression after upgrading to Next.js 16 and the offender is text, not images or ads, this is almost always a next/font fallback issue. Here is what changed and how to get back under 0.1.

Why It Happens

next/font auto-generates a fallback font with metrics matched to your webfont. In Next.js 15, those metrics were calculated at build time using a set of baked-in ascent/descent/line-gap values. In 16, the calculation changed to use the runtime font metrics API, which is more accurate but stricter, and the fallback font can now differ from the 15 output for the same webfont.

Two things go wrong during an upgrade.

Thing 1: Custom variable fonts lose their adjust values. If you loaded a variable weight font like this in 15:

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

Next.js 15 used a default size-adjust of around 107% and matched the x-height. Next.js 16 drops the default and expects you to set adjustFontFallback: true explicitly, or pass your own fallback list. If you do neither, the browser falls back to Times New Roman on the first paint, then swaps to Inter, and that swap shifts every line of text by a visible amount.

Thing 2: display: 'swap' became the default. In 15, next/font used display: 'optional' by default, which means the browser waits up to 100ms for the webfont before falling back. If the font loads within that window, there is no swap and no shift. In 16, the default is display: 'swap', which always shows the fallback first and always swaps. Good for perceived performance, bad for CLS if the fallback is mismatched.

Thing 3: Font preloading changed scope. In 15, every font imported anywhere was preloaded. In 16, fonts are only preloaded on the route that imports them. If you import the font in a shared component but use it in the root layout, the preload hint does not fire on routes that do not import that component, so the font arrives late and the swap happens further into the LCP window.

The Fix

Step 1: Always set adjustFontFallback. This is the one-line fix that resolves 90% of these regressions:

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

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

adjustFontFallback tells Next.js to generate a @font-face with size-adjust, ascent-override, descent-override, and line-gap-override values calibrated to match the target font's metrics to the fallback you specify. The generated fallback gets used until the webfont loads, and because its box dimensions match, there is no layout shift on swap.

For serif fonts use 'Times New Roman'. For monospace use 'Courier New'. Do not leave it as true; explicit string fallbacks produce more consistent results across OS font stacks.

Step 2: Apply the font variable at the <html> level. Do this in app/layout.tsx:

import { inter } from './fonts'

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en" className={inter.variable}>
      <body>{children}</body>
    </html>
  )
}

And in your global CSS:

:root {
  --font-sans: var(--font-inter), ui-sans-serif, system-ui, -apple-system,
    BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}

body {
  font-family: var(--font-sans);
}

Two reasons. Applying on <html> means every descendant inherits without re-declaring. And the CSS variable fallback chain gives you a sane OS font if the webfont plus the generated fallback both somehow fail.

Step 3: Preload critical fonts explicitly. If you use a headline font only on the homepage hero, preload it on that route:

// app/page.tsx
import { bebas } from './fonts'

export default function Home() {
  return (
    <h1 className={bebas.className}>Your headline here</h1>
  )
}

Declaring className={bebas.className} on a server component triggers the preload link. Using var(--font-bebas) alone in CSS does not. If you want to use the font only via the CSS variable, add a hidden element with the className to force preload:

<span className={bebas.className} style={{ display: 'none' }} aria-hidden />

Ugly, but it shaves roughly 200ms off font load on slow connections.

Step 4: Measure with field data, not just Lighthouse. Lighthouse runs one synthetic load; real CLS comes from real users. Add the web-vitals library and log any shift above threshold:

'use client'

import { useEffect } from 'react'
import { onCLS } from 'web-vitals'

export function Vitals() {
  useEffect(() => {
    onCLS((metric) => {
      if (metric.value > 0.1) {
        console.warn('[CLS]', metric.value, metric.entries)
      }
    })
  }, [])
  return null
}

The entries array points at the exact DOM nodes that shifted, which confirms whether it is really fonts or something else like late-loading ads. See the web.dev CLS guide for the full scoring rules.

The Lesson

next/font in 16 is better than 15 at the happy path but punishes missing config. Always set adjustFontFallback explicitly, declare the className on a server component on every route that uses the font, and measure field CLS rather than trusting Lighthouse alone.

If your Core Web Vitals tanked after a Next.js upgrade and you need someone to unwind it, I do this kind of perf work — see my services, or read the full Core Web Vitals 2026 guide for the other shifts to watch out for.

Back to blogStart a project