Problem
Upgraded a client marketing site from Next.js 15.4 to 16.2 on Monday. Lighthouse still looked green. Thursday morning the CrUX report dropped — mobile CLS jumped from 0.04 to 0.19. Real users on 4G were seeing hero text shift by a full line height roughly 700 milliseconds after paint. Desktop was fine. The homepage hadn't changed in six months.
This one surprised me. I've seen plenty of CLS from images and ads, but never from a font that had been stable through two major Next.js versions. If your CrUX or PageSpeed mobile CLS regressed after a Next.js 16 upgrade and you use next/font, this is very likely what you're hitting.
Why It Happens
next/font/google injects a @font-face rule with a generated local fallback that tries to match the metrics of the real web font. In Next.js 15 and earlier, this fallback used size-adjust, ascent-override, descent-override, and line-gap-override values derived from Google's webfont metadata at install time.
/* What next/font used to ship */
@font-face {
font-family: "Inter Fallback";
src: local("Arial");
size-adjust: 107.4%;
ascent-override: 90.2%;
descent-override: 22.4%;
line-gap-override: 0%;
}
Next.js 16 changed how that fallback is generated. The new pipeline reads metadata from the Google Fonts API at build time rather than from a static manifest. If the build runs in a sandbox with restricted network egress (Vercel preview builds without Google Fonts allow-listed, air-gapped CI), the API call fails silently and the fallback drops back to a generic adjustFontFallback: false stub with no metric overrides.
The user's browser then renders Arial at its native metrics until the web font downloads. Inter and Arial have different x-heights and cap heights. When the real font swaps in, every block of text reflows by a few pixels. Multiplied across a hero, nav, and product grid, that lands you squarely outside the 0.1 CLS threshold.
Three conditions have to be true for this to bite you:
- You upgraded to Next.js 16.0 or later
- You use
next/font/google(notnext/font/local) - Your CI or build environment cannot reach
fonts.googleapis.comat build time
Check your build logs. If you see Failed to fetch font metadata for Inter, using fallback without adjustment, you are affected.
The Fix
Two options depending on whether you want to keep using Google Fonts or pull them self-hosted.
1. Self-host the font with next/font/local and explicit metrics.
This is what I ship on every client project now. It removes the build-time network dependency entirely.
// app/lib/fonts.ts
import localFont from "next/font/local";
export const inter = localFont({
src: [
{
path: "../../public/fonts/Inter-Regular.woff2",
weight: "400",
style: "normal"
},
{
path: "../../public/fonts/Inter-SemiBold.woff2",
weight: "600",
style: "normal"
}
],
variable: "--font-inter",
display: "swap",
adjustFontFallback: "Arial",
fallback: ["Arial", "sans-serif"]
});
adjustFontFallback: "Arial" tells Next.js to compute the size-adjust and override values at build time using the actual metrics of the local .woff2 file. Those metrics ship with the font binary, so the build never has to phone home.
Download the two or three weights you actually use from Google Fonts or Fontsource and drop them into public/fonts/. Skip the weights you don't reference on first paint.
2. If you want to stay on next/font/google, pass explicit adjustment.
If self-hosting is not an option (design team wants to swap fonts from the dashboard, etc.), pin the fallback adjustment yourself:
// app/lib/fonts.ts
import { Inter } from "next/font/google";
export const inter = Inter({
subsets: ["latin"],
display: "swap",
variable: "--font-inter",
adjustFontFallback: "Arial",
preload: true
});
adjustFontFallback: "Arial" forces Next.js to compute overrides against the named fallback family using cached metrics from the installed @next/font package. It does not try to hit the Google Fonts API, so a sandboxed build still gets the right size-adjust value.
Use it with the font as a CSS variable in your root layout:
// app/layout.tsx
import { inter } from "./lib/fonts";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={inter.variable}>
<body>{children}</body>
</html>
);
}
Then in your CSS:
/* app/globals.css */
body {
font-family: var(--font-inter), Arial, sans-serif;
}
3. Verify the fix before shipping.
Open the PageSpeed Insights Lighthouse trace on your staging URL and look at the Layout Shift Culprits section. Any shift rooted in a text container should be gone. For local validation, throttle to Slow 4G in Chrome devtools and watch the performance tab's layout events:
npx lighthouse https://staging.qasimcode.com --preset=perf --view
I also like running the Chrome Web Vitals extension — it exposes the real CLS score live as you scroll, which catches shifts the automated tools miss.
Gotchas
display: 'optional' hides the issue without fixing it. Setting display: "optional" means the browser skips the web font if it is not cached. Your CLS goes to zero but your design breaks on first visit for every user. Only use it if the typography is genuinely nice-to-have.
Font preload tag placement matters. If you use next/font, Next.js inserts the <link rel="preload"> tags automatically. Do not add your own in <head> — duplicate preload hints waste connection budget and sometimes trigger warnings in Lighthouse.
Variable fonts can regress LCP if you load too many axes. A variable .woff2 with weight 100–900 and italic axis is often 120KB+. On mobile that lands squarely in your LCP budget. Ship only the axes you actually use on above-the-fold text.
For the broader Core Web Vitals picture, my Core Web Vitals guide for 2026 walks through LCP, INP, and CLS diagnostics end to end.
Need CLS Fixed Before Your CrUX Drops Again?
I diagnose Core Web Vitals regressions on Next.js and WordPress sites using real-user monitoring, not just lab scores. If your mobile CLS is bleeding rankings, send me the URL on my services page and I'll pinpoint the shift source.