The fallback option that deletes your fallback

Every webfont has a gap between the moment the page paints and the moment the real face arrives. With display: swap the browser fills that gap with something from the system, then swaps. The swap is the problem: two typefaces almost never occupy the same space, so the paragraph you were reading re-wraps under your eyes and everything below it jumps.

The standard advice is to name a fallback stack close to your webfont. Inter looks a bit like Helvetica, so you write Helvetica. This is folk medicine. Two faces looking similar at a glance says nothing about their metrics, and metrics are the entire mechanism. What shifts the page is the ratio between the em box and the ascent, descent, and advance widths, not whether the terminals are cut at the same angle.

What next/font already does

next/font/google has an adjustFontFallback option that defaults to on, and almost nobody looks at what it emits. It generates a second @font-face rule (a real one, in the stylesheet) that takes a font already on the machine and re-scales it onto your webfont’s metrics. For the Inter on this site, that rule is:

@font-face {
  font-family: "Inter Fallback";
  src: local(Arial);
  ascent-override: 90.44%;
  descent-override: 22.52%;
  line-gap-override: 0%;
  size-adjust: 107.12%;
}
Local Arial, stretched onto Inter’s own metrics. Read off the generated stylesheet, not the docs.

Four descriptors, and between them they do the whole job. size-adjust scales the glyphs so Arial’s advance widths land where Inter’s would, 107.12% because Arial is set narrower. The three *-override descriptors then pin the line box: how far above the baseline the face claims, how far below, and how much air between lines. Once all four match, a line of fallback text occupies the same rectangle as the same line of Inter.

The result is a swap you cannot see. The letterforms change (of course they do, it is a different typeface) but nothing moves. No re-wrap, no jump, no layout shift contribution.

And then you delete it

Here is the trap. next/font also takes a fallback array, and it reads exactly like the belt-and-braces thing a careful person adds:

const inter = Inter({
  subsets: ["latin"],
  variable: "--font-inter",
  display: "swap",
  fallback: ["system-ui", "-apple-system", "sans-serif"], // ← the mistake
})
Looks like insurance. Is a downgrade.

That array does not extend the generated face. It replaces it. The Inter Fallback family stops being emitted, and the browser falls back to system-ui, whose metrics are whatever the operating system happened to ship, unadjusted, unrelated to Inter. You have traded a face measured onto your typeface for one that merely has a similar reputation, and you have reintroduced the exact shift the option existed to remove.

The failure is quiet in the worst way. Nothing errors. The font still loads. The site looks right on your machine, on a warm cache, on the third reload. It is only wrong on a cold first paint on a slow connection, which is to say, it is only wrong for the visitors who have never seen your work before.

Where the system stack actually belongs

You do still want a system stack. It just belongs downstream of the metric match, not in place of it. Leave fallback off entirely, and put the system faces after the variable in your theme:

@theme inline {
  --font-sans:
    var(--font-inter), -apple-system, BlinkMacSystemFont, "Segoe UI",
    "Helvetica Neue", Arial, sans-serif;
}
--font-inter already expands to "Inter", "Inter Fallback". The system faces come after that pair, not instead of it.

The variable next/font hands you is not one family name, it is the pair, "Inter", "Inter Fallback", and that pair is the shift-free part. Everything you append after it is the genuine last resort: the case where both the webfont and local Arial are unavailable, which on a real machine means something has gone wrong that a font stack is not going to fix. Order is the whole point here. After the var, never in place of it.

How to check yours

  • Open the generated font stylesheet in the Network panel and search for Fallback. If there is no @font-face with an ascent-override, you don’t have one.
  • Set the network to a slow profile and hard-reload with the cache disabled. Watch the paragraph, not the heading: a heading is one line and hides the re-wrap.
  • Look at Layout Shift regions in the Rendering panel. A metric-matched swap contributes nothing; an unmatched one lights up the whole column.

It is a two-line change and it costs nothing at runtime, which makes it the cheapest layout-shift fix available in a Next.js app. The catch is that the wrong version and the right version look identical in a code review. The wrong one just has one more line, and the line looks responsible.