
How to load custom fonts efficiently in Astro?
The most efficient way to load custom fonts in Astro is the built-in Fonts API, which has been stable since Astro 6. You register each font in the top-level fonts array of astro.config.mjs with a name, a cssVariable and a provider such as fontProviders.local() or fontProviders.fontsource(). Then you add <Font cssVariable="--font-inter" /> from astro:assets to your page head and use font-family: var(--font-inter) in CSS. Astro downloads the files at build time, serves them from your own domain, writes the @font-face rules, generates metric-adjusted fallback fonts to reduce layout shift, and can add preload links for the files you choose.
Before this API existed, Astro sites usually loaded fonts from Google's CDN or hand-wrote @font-face rules for self-hosted files. Both still work, but the Fonts API gives you the performance best practices by default. In this article you'll learn how to configure local fonts and provider fonts, apply them with the <Font /> component, preload only what matters, tune fallbacks and font-display, limit downloads to the weights and subsets you use, use the fonts with Tailwind, and understand caching. Everything here was built and inspected with Astro 7.4.2.
Why Use the Fonts API
Web fonts affect performance in three ways: how many bytes are downloaded, how soon the download starts, and how much the layout shifts when the font arrives. The Fonts API addresses all three:
- Self-hosting: font files from providers are downloaded at build time and served from
/_astro/fonts/, so there's no extra connection to a third-party domain and no visitor data is sent to it. - Minimal downloads: by default only weight 400, normal and italic styles, the Latin subset and WOFF2 files are included. You opt in to anything else.
- Optimised fallbacks: Astro generates a fallback
@font-facewithsize-adjustand metric overrides, so the system font takes up nearly the same space as the web font. - Controlled preloading: you decide per font, and even per file, what gets a
<link rel="preload">. - Type safety:
cssVariablevalues are typed, so a typo in<Font cssVariable="..." />is caught byastro check.
Using a Local Font File
If you have the font files yourself, for example a licensed typeface or a variable font downloaded from the designer, put them in src/:
src/
└── assets/
└── fonts/
└── InterVariable.woff2
Then register the font with the local provider:
// astro.config.mjs
import { defineConfig, fontProviders } from "astro/config";
export default defineConfig({
fonts: [
{
provider: fontProviders.local(),
name: "Inter",
cssVariable: "--font-inter",
fallbacks: ["sans-serif"],
options: {
variants: [
{
src: ["./src/assets/fonts/InterVariable.woff2"],
weight: "100 900",
style: "normal",
},
],
},
},
],
});
Each object in variants becomes one @font-face rule. For a variable font, weight is a range string like "100 900". For static fonts, add one variant per file with a single weight such as 400 or 700. If you leave out weight and style, Astro tries to infer them from the file. The src paths are relative to the project root.
Using a Font Provider
For open-source fonts you don't need to download anything. Astro has built-in providers for Adobe, Bunny, Fontshare, Fontsource, Google, Google Icons and NPM. Here's Lora from Fontsource:
// astro.config.mjs
import { defineConfig, fontProviders } from "astro/config";
export default defineConfig({
fonts: [
{
provider: fontProviders.fontsource(),
name: "Lora",
cssVariable: "--font-lora",
weights: [400, 700],
styles: ["normal"],
fallbacks: ["serif"],
},
],
});
The name must match how the provider identifies the font. During the build, Astro fetched the two requested files and logged:
[assets] Copying fonts (3 files)...
Three files: one for local Inter, two for Lora's weights. For a variable font from a provider, pass a range in weights, for example weights: ["300 700"].
Applying Fonts with the Font Component
Registering a font doesn't add anything to your pages yet. You include it where it's needed with the <Font /> component, usually in your base layout's head:
---
// src/layouts/BaseLayout.astro
import { Font } from "astro:assets";
import "../styles/global.css";
const { title } = Astro.props;
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>{title}</title>
<Font cssVariable="--font-inter" preload />
<Font cssVariable="--font-lora" />
</head>
<body>
<slot />
</body>
</html>
Then use the CSS variables anywhere:
/* src/styles/global.css */
body {
font-family: var(--font-inter);
}
h1,
h2 {
font-family: var(--font-lora);
}
Because <Font /> is per page, you can include a font only in the layouts that use it. A display font used only on the homepage doesn't need to load on every blog post.
What Astro Generates
Here's the relevant part of the built <head> for the Inter configuration above:
<style>
@font-face{font-family:Inter-57eae95c9bd7fcb7;src:url("/_astro/fonts/cb13050e68e771d7.woff2") format("woff2");font-display:swap;font-weight:100 900;font-style:normal;}
@font-face{font-family:"Inter-57eae95c9bd7fcb7 fallback: Arial";src:local("Arial");font-display:swap;font-weight:100 900;font-style:normal;size-adjust:107.1194%;ascent-override:90.4365%;descent-override:22.518%;line-gap-override:0%;}
:root{--font-inter:Inter-57eae95c9bd7fcb7,"Inter-57eae95c9bd7fcb7 fallback: Arial",sans-serif;}
</style>
<link rel="preload" href="/_astro/fonts/cb13050e68e771d7.woff2" as="font" type="font/woff2" crossorigin>
A few things to notice:
- The family name is hashed: you never type
"Inter"in CSS. Always use the variable, which contains the full stack. - The fallback is metric-matched: because the last fallback is the generic
sans-serif, Astro picked Arial and computedsize-adjustand override values so it occupies almost the same space as Inter. This is the technique that reduces layout shift when the web font swaps in. font-display: swapis the default: text appears immediately in the fallback font.- The preload link includes
crossorigin: required for fonts, and easy to forget when writing it by hand.
For Lora, a serif, Astro generated fallbacks based on Times New Roman, including a separate one for the bold weight, and added a unicode-range for the Latin subset so the file is only downloaded when Latin characters are present.
Preload Only What You Need
Preloading tells the browser to fetch a file immediately, before it has discovered that the page needs it. That helps the font used for above-the-fold text, and hurts if you preload files the page doesn't use, because they compete with other critical resources.
The preload prop accepts true for every file of a font, or an array of filters with weight, style and subset:
<Font cssVariable="--font-inter" preload />
<Font cssVariable="--font-lora" preload={[{ weight: 700 }]} />
With this change, the build produced exactly two preload links: one for Inter's single variable file and one for Lora 700, used by the headings. Lora 400 was declared but not preloaded. For variable fonts, a file is preloaded if the requested weight falls within its range, so { weight: 400 } matches a 100 900 file.
A sensible rule: preload at most the body font and the heading font, and only the weights visible on first paint.
Tuning Fallbacks and font-display
Choose the Right Generic Family
The optimised fallback is generated from the last entry in fallbacks, if it's a generic family. The default is ["sans-serif"], which is wrong for serif or monospace fonts, so set it explicitly:
{
provider: fontProviders.fontsource(),
name: "JetBrains Mono",
cssVariable: "--font-mono",
fallbacks: ["monospace"],
}
You can list specific fonts before the generic one, such as ["Georgia", "serif"]. To turn off the metric adjustment, set optimizedFallbacks: false. To remove fallbacks entirely, use an empty array, though that's rarely a good idea.
Pick a font-display Strategy
The display option sets font-display on the generated rules. It defaults to "swap":
{
provider: fontProviders.fontsource(),
name: "Lora",
cssVariable: "--font-lora",
display: "optional",
}
With "optional", the built CSS contained font-display:optional. That value lets the browser skip the web font on slow connections and keep the fallback, eliminating late swaps entirely. It's a good fit for decorative fonts. For body text, "swap" combined with the optimised fallback is usually the best balance. The effect on layout shift is discussed in the post on Core Web Vitals in Astro.
Download Only What You Use
Every extra weight, style and subset is another file. The defaults are deliberately small:
| Option | Default | When to change it |
|---|---|---|
weights | [400] | Add each weight your CSS actually uses, or a range for variable fonts |
styles | ["normal", "italic"] | Use ["normal"] if you never use italics |
subsets | ["latin"] | Add "latin-ext", "cyrillic" and so on for other languages |
formats | ["woff2"] | Rarely; WOFF2 is supported by every current browser |
display | "swap" | "optional" for decorative fonts |
For fine-grained control, declare the same font more than once with the same name, cssVariable and provider. Astro merges the entries and downloads only the combinations you listed:
// astro.config.mjs
import { defineConfig, fontProviders } from "astro/config";
export default defineConfig({
fonts: [
{
name: "Roboto",
cssVariable: "--font-roboto",
provider: fontProviders.google(),
weights: [500, 600],
styles: ["normal"],
},
{
name: "Roboto",
cssVariable: "--font-roboto",
provider: fontProviders.google(),
weights: [500],
styles: ["italic"],
},
],
});
This fetches normal 500 and 600, plus italic 500 only. The unicodeRange option is also available when part of a site uses a different script and you want the browser to download that font only on pages that contain those characters.
Using the Fonts with Tailwind CSS
With Tailwind 4, map the font's CSS variable to a theme variable using @theme inline:
/* src/styles/global.css */
@import "tailwindcss";
@theme inline {
--font-sans: var(--font-inter);
--font-serif: var(--font-lora);
}
inline matters: because these theme values reference other variables, it tells Tailwind to put the var(--font-inter) reference directly into the generated utilities, which is what the Astro docs recommend. Now font-sans and font-serif utilities use your fonts. The rest of the Tailwind setup is covered in the post on Tailwind CSS in Astro.
Caching and Build Output
In production builds, font files are copied to dist/_astro/fonts/ with hashed names, so they can be served with long-lived cache headers like the rest of your static assets. Downloads from providers are cached between builds in node_modules/.astro/fonts; in my sandbox that folder held the three .woff2 files plus a provider cache directory. In development, the cache lives in .astro/fonts.
If a font looks stale after you change its configuration, delete the relevant cache folder and rebuild.
Using Font Files in Code
Astro also exposes the downloaded fonts programmatically through fontData from astro:assets, keyed by cssVariable. Each entry lists the files and their URLs, which is useful when another tool needs the raw font, such as Satori when generating Open Graph images in an endpoint. The docs pair it with experimental_getFontFileURL() to turn a font path into a fetchable URL; note that this helper is still marked experimental. The post on Open Graph images in Astro shows this kind of endpoint in practice.
Migrating from Older Approaches
If your project predates the Fonts API, you probably have one of these:
- A Google Fonts
<link>tag: replace it withfontProviders.google()and a<Font />component. The fonts are then self-hosted, which removes the third-party connection. - Fontsource packages imported in a layout, like
import "@fontsource/inter";: replace them withfontProviders.fontsource()and uninstall the packages. - Hand-written
@font-facerules in a global stylesheet: move the files intosrc/assets/fonts/and usefontProviders.local(). experimental.fonts: truefrom Astro 5: remove the flag, since the API is stable in Astro 6 and later.
FAQ: Custom Fonts in Astro
No. It became stable in Astro 6, so you configure it with the top-level fonts option in astro.config.mjs without an experimental flag. Only the experimental_getFontFileURL helper keeps an experimental prefix.
No. Astro downloads the files at build time and serves them from your own site under _astro/fonts, so visitors' browsers never contact the provider.
Astro gives each font a hashed family name to avoid collisions. Use the CSS variable you configured, such as var(--font-inter), which contains the font and its fallbacks.
No. Preload only the files needed for text visible on first paint, usually the body font and one heading weight. The preload prop accepts an array of weight, style and subset filters for this.
For a local file, set the variant's weight to a range string such as 100 900. For providers, set weights to an array containing a range, such as 300 700 as a single string.
Put them in the src folder, for example src/assets/fonts, and reference them from the variant's src array with a path relative to the project root.
Conclusion
The Astro Fonts API turns font loading into configuration: declare each font once in astro.config.mjs, add <Font /> to the head of the layouts that use it, and reference the CSS variable in your styles. Behind that small surface, Astro self-hosts the files, keeps downloads to the weights and subsets you asked for, writes the @font-face rules and generates metric-adjusted fallbacks that reduce layout shift.
To make it efficient rather than just convenient, be deliberate about three settings: list only the weights and styles you use, set fallbacks to the right generic family, and preload only the files needed for the first screen. With those in place, custom fonts cost you a few small, cached WOFF2 files and almost no layout shift.


