My site uses two variable fonts, Archivo for body text and Newsreader for headings, both via the @fontsource-variable packages. The default setup is a one-liner import per font and it works fine. I started looking into what the imports actually generate and ended up replacing them with manual declarations to cut down on unnecessary rules. Here's what I changed and why.
What fontsource does by default
The standard fontsource setup looks like this:
import "@fontsource-variable/archivo";
import "@fontsource-variable/newsreader";
import "@fontsource-variable/newsreader/wght-italic.css";Each of those imports injects a stylesheet with multiple @font-face rules, one per Unicode subset the package supports. For Archivo that includes latin, latin-extended, and vietnamese. For an English-only site, the latin-extended and vietnamese subsets will never match any characters on the page, but the browser still parses and registers those @font-face rules.
Counting them up: Archivo ships 3 subsets (6 @font-face rules when you include the weight range declarations), Newsreader adds another 3 × 2 for normal and italic, so you end up with around 10–12 @font-face declarations when you only need 3. They're cheap to parse, but they're noise, and writing the rules manually means you're in full control of what gets registered.
The manual declarations
The fontsource packages already contain the exact CSS you need. Inside node_modules/@fontsource-variable/archivo/ there are individual subset CSS files: archivo-latin.css, archivo-latin-ext.css, and so on. Open the latin one and you'll find a ready-made @font-face block with the correct font-weight range, unicode-range, and src pointing at the .woff2 file.
I copied those blocks directly into global.css and updated the src paths to point into node_modules (Vite resolves and hashes them at build time):
@font-face {
font-family: "Archivo Variable";
font-style: normal;
font-display: swap;
font-weight: 100 900;
src: url("../node_modules/@fontsource-variable/archivo/files/archivo-latin-wght-normal.woff2")
format("woff2-variations");
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: "Newsreader Variable";
font-style: normal;
font-display: swap;
font-weight: 200 800;
src: url("../node_modules/@fontsource-variable/newsreader/files/newsreader-latin-wght-normal.woff2")
format("woff2-variations");
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: "Newsreader Variable";
font-style: italic;
font-display: swap;
font-weight: 200 800;
src: url("../node_modules/@fontsource-variable/newsreader/files/newsreader-latin-wght-italic.woff2")
format("woff2-variations");
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
U+2000-206F, U+20AC, U+2122, U+2215, U+FEFF, U+FFFD;
}The values are copied straight from the package, so there's nothing to derive or guess.
Adding preload hints
@font-face declarations are discovered lazily, and the browser only fetches a font file once it encounters text that needs it. For above-the-fold content this is too late, and you get a flash of unstyled text while the font loads. Preload hints fix this by telling the browser to fetch the font files early, before the CSS has even been parsed.
In TanStack Start, import the font files with Vite's ?url suffix to get the hashed output path at build time, then pass them to the root route's head() function:
import archivoFont from "@fontsource-variable/archivo/files/archivo-latin-wght-normal.woff2?url";
import newsreaderItalicFont from "@fontsource-variable/newsreader/files/newsreader-latin-wght-italic.woff2?url";
import newsreaderFont from "@fontsource-variable/newsreader/files/newsreader-latin-wght-normal.woff2?url";
export const fontPreloadLinks = [
{ rel: "preload", href: archivoFont, as: "font", type: "font/woff2", crossOrigin: "anonymous" },
{
rel: "preload",
href: newsreaderFont,
as: "font",
type: "font/woff2",
crossOrigin: "anonymous",
},
{
rel: "preload",
href: newsreaderItalicFont,
as: "font",
type: "font/woff2",
crossOrigin: "anonymous",
},
];import { fontPreloadLinks } from "@/lib/fonts";
export const Route = createRootRoute({
head: () => ({
links: [
...fontPreloadLinks,
// other links...
],
}),
});The crossOrigin: "anonymous" attribute is required for font preloads. Without it the browser fetches the font twice.
What this gives you
Going from default fontsource imports to manual declarations:
- Fewer
@font-facerules, 3 instead of ~12. Latin-only means no rules the browser registers and then never uses. - The packages are still doing the heavy lifting, you keep
@fontsource-variableas the source of truth for the actual font files and the correct unicode-range values. You're just writing the CSS rules yourself rather than relying on the auto-injected ones.
Wrapping up
The main win is dropping from ~12 @font-face rules to 3. It's a small change, but it removes rules the browser was registering and never using, and puts you in control of exactly what gets loaded. The fontsource packages are still doing the work of providing the font files and the correct unicode-range values, so you're not giving up much convenience to get there.
Continue reading
Mar 2026
·5 min read
Mar 2026
·5 min read