Self-Hosted Fonts: Lazy Loading and Preload Options
Need this adapted for your organization? Work with AstroWeb.dev — Astro services by NViewsWEB.
Feature

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.css for faces with lazy: false (loaded with the main stylesheet),
  • core/styles/non-critical/fonts.css for faces with lazy: true,
  • <link rel="preload"> tags, which core/components/Fonts.astro prints at render time for faces with preload: 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

  1. Put a subset .woff2 in public/fonts/.
  2. Add a face to faces and update family if it replaces the base or heading font.
  3. Run pnpm run generate-css (or pnpm run dev), which regenerates the font CSS and bundles.
  4. 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.