If you're running a modern Ember app with Embroider and Vite, adding custom fonts is easy. Fontsource is a good way to do it: fonts are packaged as npm dependencies, so you get versioned, self-hosted fonts with zero config on the bundler side.
Here's how it works in practice.
Install the font packages
Pick the fonts you need from fontsource.org and install them:
pnpm add @fontsource/geist @fontsource/geist-mono
Import the CSS
In your app/app.css (or wherever your main stylesheet lives), import the weights you actually use:
@import "@fontsource/geist/400.css";
@import "@fontsource/geist/500.css";
@import "@fontsource/geist/600.css";
@import "@fontsource/geist/700.css";
@import "@fontsource/geist-mono/400.css";
@import "@fontsource/geist-mono/500.css";
Each of these CSS files contains the @font-face declarations for that specific weight. The font files (woff2) ship as part of the npm package and Vite resolves them automatically.
Only import the weights you use. Every weight you add is another font file your users have to download.
Use the fonts in CSS
Reference the font family names in your styles:
body {
font-family: "Geist", sans-serif;
}
code, pre {
font-family: "Geist Mono", monospace;
}
If you're using Tailwind v4, you can set them as theme variables instead:
@theme {
--font-sans: "Geist", sans-serif;
--font-mono: "Geist Mono", monospace;
}
Tailwind will then use these as the default font-sans and font-mono utilities.
Preloading fonts to avoid FOUT
The setup above works, but you'll probably see a flash of unstyled text (FOUT) on first load. The browser discovers the font files only after parsing the CSS, which is late.
You can fix this by preloading the woff2 files. In an Embroider app with Vite, you can use the ?url import suffix to get the resolved URL for a font file, then inject <link rel="preload"> tags early.
Create a file like app/lib/preload-fonts.ts:
import geist400 from "@fontsource/geist/files/geist-latin-400-normal.woff2?url";
import geist500 from "@fontsource/geist/files/geist-latin-500-normal.woff2?url";
import geist600 from "@fontsource/geist/files/geist-latin-600-normal.woff2?url";
for (const href of [geist400, geist500, geist600]) {
const link = document.createElement("link");
link.rel = "preload";
link.as = "font";
link.type = "font/woff2";
link.href = href;
link.crossOrigin = "anonymous";
document.head.appendChild(link);
}
Then import it from your app/app.ts entrypoint:
import './lib/preload-fonts';
The ?url suffix is a Vite feature. It makes the import return the final, hashed URL of the asset. In production, this will be something like /assets/geist-latin-400-normal-abc123.woff2. This means your preload links always point to the correct file, even after builds with content hashing.
You only need to preload fonts that are used above the fold. If you have a monospace font that only shows up in specific views, skip preloading it.
Variable fonts
If the font supports it, Fontsource also ships variable font versions. Instead of importing individual weights, you import a single file that covers all weights:
@import "@fontsource-variable/geist";
@import "@fontsource-variable/geist-mono";
The package name changes from @fontsource/ to @fontsource-variable/:
pnpm add @fontsource-variable/geist @fontsource-variable/geist-mono
One file per font, all weights included. The browser only downloads a single woff2 file and interpolates between weights. This is usually smaller than loading 4+ static weight files.
Why Fontsource over Google Fonts?
- Fonts are self-hosted. No third-party requests.
- You control exactly which weights and subsets are included.
- Fonts are versioned alongside your app. No CDN surprises.
- Works with any bundler that can resolve
node_modulesCSS imports (Vite, Webpack, etc).
That's it
Three steps: install the package, import the CSS weights, and reference the font family. Preloading is optional but helps with perceived performance. The whole thing works because Embroider + Vite resolves npm CSS imports and static assets the same way any Vite app would.
Top comments (0)