Self-Hosted Fonts: Lazy Loading and Preload Options
Declare fonts once in config. The build writes the @font-face CSS, preloads the critical face and loads the rest after first paint, with no font CDN.
Fonts are served from your own domain, which keeps them inside the font-src 'self' rule of the security policy and removes a third-party connection from the critical path. Everything is driven by one file: src/site/config/styles/fonts.mjs.
One config, three outputs
core/scripts/generate/fonts-css.mjs reads the faces list and writes:
core/styles/main/fonts.cssfor faces withlazy: false(loaded with the main stylesheet),core/styles/non-critical/fonts.cssfor faces withlazy: true,<link rel="preload">tags, whichcore/components/Fonts.astroprints at render time for faces withpreload: true.
The same file also defines the typography tokens: fluid size steps built with clamp(), weight, lineHeight, the family aliases (base, heading, mono) and the maps that assign them to h1 to h6 and body text.
A face
{
family: 'Plus Jakarta Sans',
weight: '400 800', // variable font range
style: 'normal',
display: 'swap',
local: ['Plus Jakarta Sans'],
src: [{ url: '/fonts/PlusJakartaSans-VariableFont_wght-subset.woff2', format: 'woff2' }],
unicodeRange: 'U+0020-007E, ...',
lazy: false, // eager bundle
preload: true, // emit <link rel="preload">
}
| Option | Effect |
|---|---|
lazy |
false puts the rule in the main CSS; true puts it in the non-critical CSS |
preload |
true adds a preload link for each src URL |
preloadFetchPriority, fetchPriority |
high, low or auto; preload defaults to high |
crossorigin |
Defaults to anonymous, required for font preloads; false omits it |
media |
Limits the preload to a media query |
display |
The font-display value, swap in the demo |
local |
Uses an installed copy first, avoiding a download |
unicodeRange |
Subsets the face so the browser only downloads it when those characters appear |
How lazy loading works
Stylesheets for the components and non-critical branches are written as <link rel="stylesheet" data-href="..."> with no href, so the browser does not fetch them while rendering. core/js/individual/worker-lazy-loader.js swaps data-href to href after the page is up. Lazy faces therefore arrive after first paint, and the text shows in the fallback stack until then (font-display: swap).
The demo uses this split:
| Face | Setting | Why |
|---|---|---|
| Plus Jakarta Sans, upright, variable | lazy: false, preload: true |
Body and headings, needed immediately |
| Plus Jakarta Sans, italic | lazy: true |
Only needed for emphasis |
| JetBrains Mono | lazy: true |
Code blocks and paths, usually below the fold |
Add or change a font
- Put a subset
.woff2inpublic/fonts/. - Add a face to
facesand updatefamilyif it replaces the base or heading font. - Run
pnpm run generate-css(orpnpm run dev), which regenerates the font CSS and bundles. - Reload and confirm the preload in the page head and the font request in the network panel.
Preload only what the first screen needs. Each preload competes with the hero image and the main CSS.
No font CDN
The theme itself uses no font CDN. allowGoogleFonts in src/site/config/functions.mjs only decides whether the generated policy also allows fonts.googleapis.com and fonts.gstatic.com; set it to false to remove them from the policy.