nextjs-font-css-variable-collision
next/font CSS Variable Name Collision
Problem
When next/font configuration uses the same CSS custom property names as your
design system (e.g., both define --font-display), the CSS cascade determines
which value wins based on source order in the compiled CSS bundle. This creates
a latent bug that can appear or disappear when unrelated changes (like adding
new font imports) cause webpack to reorder CSS chunks.
The result: @font-face registers fonts under hashed internal names (e.g.,
'__Instrument_Serif_315a98'), but the CSS variable resolves to the literal
name ('Instrument Serif') which has no matching @font-face rule. The browser
falls back to the next font in the stack (e.g., Georgia).
Context / Trigger Conditions
- Next.js app using
next/font/googleornext/font/local - Design system in
globals.cssor similar file defines CSS custom properties with the same names asnext/font'svariableoption (e.g.,--font-display,--font-body,--font-heading) - Fonts suddenly look wrong after adding or removing font imports
- DevTools Computed tab shows fallback fonts (Georgia, system-ui) instead of the loaded web font
- Inspecting the
<html>element shows CSS variables holding literal font names instead of hashed__FontName_hashvalues
Root Cause
Both systems try to set the same CSS custom property:
/* next/font generates a class (specificity 0,1,0): */
.__variable_315a98 {
--font-display: '__Instrument_Serif_315a98', '__Instrument_Serif_Fallback_315a98';
}
/* globals.css :root block (specificity 0,1,0): */
:root {
--font-display: 'Instrument Serif', Georgia, serif;
}
Both have specificity (0,1,0). The later rule in the CSS bundle wins.
- If
next/font's class appears after:root→ hashed name wins (fonts work) - If
:rootappears afternext/font's class → literal name wins (fonts break)
Why this is latent: CSS chunk ordering in webpack/turbopack is non-deterministic
and changes when you add/remove imports, modify next.config.js, or update
dependencies. The collision exists from day one but only manifests when ordering
flips.
Solution
Step 1: Give next/font unique variable names
Rename the variable option in each font declaration to a name that won't
collide with your design system:
// BEFORE (collision-prone):
const instrumentSerif = Instrument_Serif({
variable: "--font-display", // collides with globals.css :root
...
});
// AFTER (collision-free):
const instrumentSerif = Instrument_Serif({
variable: "--font-instrument-serif", // unique source variable
...
});
Naming convention: Use --font-{font-family-name} for the source variable.
This describes which font, not what role it plays.
Step 2: Reference source variables in the design system
Update your :root design tokens to consume the source variables via var():
/* BEFORE: */
:root {
--font-display: 'Instrument Serif', Georgia, serif;
--font-body: 'Satoshi', 'SF Pro Display', system-ui, sans-serif;
--font-handwriting: 'Caveat', cursive;
}
/* AFTER: */
:root {
--font-display: var(--font-instrument-serif), Georgia, serif;
--font-body: var(--font-plus-jakarta), 'SF Pro Display', system-ui, sans-serif;
--font-handwriting: var(--font-caveat), cursive;
}
Step 3: Leave all component code unchanged
Components continue using semantic tokens like var(--font-display). The
indirection is invisible to consumers. CJK :lang() overrides that set
--font-display directly still work because they override the semantic
variable, not the source variable.
Architecture: Two-Layer Variable Pattern
Layer 1 (Source): --font-instrument-serif ← set by next/font class
↓ var()
Layer 2 (Semantic): --font-display ← set by :root, consumed by components
↓ var()
Components: font-family: var(--font-display)
- Source variables (
--font-instrument-serif) describe WHICH font - Semantic variables (
--font-display) describe WHAT ROLE the font plays - Components only reference semantic variables
- CJK locale overrides set semantic variables directly
Verification
- Build:
npm run build— zero errors - DevTools: Inspect
<html>element:--font-instrument-serif→ hashed name like'__Instrument_Serif_315a98'--font-display→ resolves to the hashed name viavar()
- Computed font: Select a heading → Computed tab →
font-familyshows the loaded web font, not the fallback - All locales: Verify English, CJK, and other locale pages render correctly
Example
Complete layout.tsx font configuration:
import { Instrument_Serif, Plus_Jakarta_Sans, Caveat } from "next/font/google";
const instrumentSerif = Instrument_Serif({
subsets: ["latin"],
weight: ["400"],
variable: "--font-instrument-serif", // unique, no collision
display: "swap",
});
const plusJakarta = Plus_Jakarta_Sans({
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
variable: "--font-plus-jakarta", // unique, no collision
display: "swap",
});
const caveat = Caveat({
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
variable: "--font-caveat", // unique, no collision
display: "swap",
});
// In JSX — className applies the generated CSS classes:
<html className={`${instrumentSerif.variable} ${plusJakarta.variable} ${caveat.variable}`}>
Notes
- This pattern also works with
next/font/local— samevariableoption - The
.style.fontFamilyproperty on font objects is unaffected by the variable rename — it always returns the hashed name and can be used for inline styles - If you use Tailwind's
fontFamilyconfig to reference CSS variables, ensure it references the semantic variables (e.g.,--font-display), not the source variables - This is not specific to any font — it can happen with ANY
next/fontwhere thevariablename matches a CSS custom property in your stylesheets