I had just moved a client's site onto Next.js with Tailwind v4, and one of the first things I wired up was the typography. The design called for a single clean Google font, so I loaded it via next/font, exposed it as a CSS variable, and registered that variable inside Tailwind's @theme block. It all felt correct in my head. But the moment I opened the page, the text fell back to the default sans stack. Not the font I picked, just a dull generic Arial-ish thing.
What made it maddening: there was no error. No console warning, no red line in the terminal, next dev stayed green. The font variable was clearly present — I could see it attached to the html element in DevTools. The font-sans class was on the element I inspected too. But the --font-sans that was supposed to point at my font came back empty.
The setup I thought was correct
The config looked roughly like this. next/font gets loaded and handed a variable name:
import { Quicksand } from "next/font/google";
const quicksand = Quicksand({
subsets: ["latin"],
variable: "--font-quicksand",
});The variable gets attached to html, and in globals.css I assumed it was enough to reference it inside @theme:
@theme {
--font-sans: var(--font-quicksand), ui-sans-serif, system-ui, sans-serif;
}The logic: Tailwind would produce a font-sans token pointing at var(--font-quicksand), and next/font would fill that variable in at runtime. Looks tidy. In reality it did nothing at all.
Why this happens
It took me a while to spot the root cause, and once it clicked it felt obvious. The @theme block in Tailwind v4 is resolved at build time. Tailwind reads the contents of @theme, computes every token, and writes them out as CSS custom properties in its generated stylesheet. That whole process happens at build time, inside Tailwind's pipeline, before any browser is involved.
The problem is that var(--font-quicksand) is a runtime variable. Its value only exists after next/font injects that variable into the DOM on the client. While Tailwind is resolving @theme at build time, --font-quicksand has no value yet, so the --font-sans token Tailwind emits ends up empty or invalid. The font-sans class still exists, but it points at a token holding no font. The result falls back.
So it was not next/font failing, and the variable was not misnamed. The mistake was placing a runtime variable reference in a place that only understands build-time values. Two different worlds, and I crossed them at the wrong point.
The fix
The key is to separate two concerns: the token Tailwind needs at build time, and the runtime font that next/font injects.
First, inside @theme, use the literal font family name, not var(). This gives Tailwind a valid, concrete token at build time:
@theme {
--font-sans: "Quicksand", ui-sans-serif, system-ui, sans-serif;
}Then, separately, apply the runtime variable from next/font directly in a plain CSS rule on body:
body {
font-family: var(--font-quicksand), ui-sans-serif, system-ui, sans-serif;
}Now the flow is right. Tailwind has a valid literal token at build time, so font-sans is no longer empty. And body uses the runtime variable that next/font actually fills on the client, so the real font shows. As soon as I reloaded, the text switched straight to Quicksand. No more dull fallback.
One small but important detail: do not rely on var() inside @theme for anything whose value only appears at runtime. @theme is not the place for dynamic values. When a variable's value is injected later by JavaScript or by a loader like next/font, its reference has to live in plain runtime CSS, not inside Tailwind's build-time block.
The takeaway
The Tailwind v4 plus next/font combination has a subtle trap right on the boundary between build time and runtime. @theme is build time, next/font is runtime, and the var() bridging them silently resolves to empty. The rule of thumb I hold now: put a literal family value in @theme so Tailwind gets a legitimate token, and split the next/font runtime variable into a plain CSS rule on body. Since then, whenever a font refuses to apply even though its variable is clearly defined, my first question is no longer "what is wrong with the variable" but "when does this value resolve — at build or at runtime?".
