Self-Hosting Fonts in Vite and Astro

This guide is part of the Loading Web Fonts in JavaScript Frameworks section, itself part of the Font Loading & Delivery Strategies for the Web area.

Vite and Astro do not have a built-in font module the way Next.js does. There is no next/font equivalent that auto-generates size-adjust fallbacks or injects preload tags for you. Instead, both tools treat a font file as a plain static asset: you import it, the bundler fingerprints the filename with a content hash, and you are responsible for writing the @font-face rule and the <link rel="preload"> tag yourself against whatever hashed URL the build emits. That manual step is exactly where teams get it wrong — either the @font-face src points at a stale pre-build path, or the preload tag references a hash from a previous build and 404s in production while working perfectly in dev. This page shows the asset-import pattern that keeps the CSS and the preload tag in sync with the hash Vite or Astro actually produces, in both frameworks, plus the verification step that catches drift before it ships.

Prerequisites

  • A Vite project (plain Vite, or a framework built on it such as SvelteKit, Vue, or a React app) with fonts in WOFF2 format — see Font Format Strategy: WOFF2, WOFF, TTF if you are still shipping TTF or uncompressed WOFF.
  • An Astro project on Astro 3+ (the asset-import behavior described here is stable from that version onward).
  • Familiarity with font-display — this guide assumes swap or optional; see font-display Values: swap, optional, fallback Explained for the full decision matrix.
  • Fonts already subset to the character ranges you actually use, ideally via the workflow in Unicode-Range & Font Subset Loading Guide, so the hashed file you are fingerprinting is already as small as it can be.

Implementation: Vite asset import with a generated hash

In Vite, importing a font file from a JS/TS module (or referencing it with ?url) returns the final, hashed public path as a string at build time. That string is the single source of truth for both the @font-face src and any preload tag — you never hand-write the path.

// src/fonts.ts
import interRegularUrl from './assets/fonts/inter-subset-regular.woff2?url';
import interBoldUrl from './assets/fonts/inter-subset-bold.woff2?url';

export const fontUrls = {
  regular: interRegularUrl,
  bold: interBoldUrl,
};
// src/main.ts
import { fontUrls } from './fonts';

const style = document.createElement('style');
style.textContent = `
  @font-face {
    font-family: 'Inter';
    src: url('${fontUrls.regular}') format('woff2');
    font-weight: 400;
    font-display: swap;
  }
  @font-face {
    font-family: 'Inter';
    src: url('${fontUrls.bold}') format('woff2');
    font-weight: 700;
    font-display: swap;
  }
`;
document.head.appendChild(style);

const preload = document.createElement('link');
preload.rel = 'preload';
preload.as = 'font';
preload.type = 'font/woff2';
preload.crossOrigin = 'anonymous';
preload.href = fontUrls.regular;
document.head.appendChild(preload);

Line by line: the ?url suffix on the import tells Vite's asset pipeline to resolve the module to its final built URL — something like /assets/inter-subset-regular.4f2a91c3.woff2 — rather than inlining the file or returning a data URI. font-display: swap avoids an invisible-text block period; if this is body copy you can also consider optional once you have measured repeat-visit cache hit rates. The preload <link> is created with crossOrigin = 'anonymous' — this is not optional decoration; without it the browser performs an anonymous-mode fetch for the preload but a different credential mode for the actual @font-face load, so the two requests don't match and the browser fetches the font twice. If you're unsure why that pairing matters, the mechanics are covered in Why Font Preload Needs the crossorigin Attribute. Because the hash changes on every content change, cache invalidation is automatic — pair it with a long max-age as described in Cache-Control: immutable for Long-Lived Font Files so repeat visits skip revalidation entirely.

If you're on a framework layered over Vite (SvelteKit, standard Vue/React SPA), inject the preload in the framework's document head hook instead of via JS after paint — appending the <link> from main.ts as above works for a pure SPA, but for anything server-rendered you want the tag in the initial HTML so the browser discovers it during the preload scanner pass, before it has even parsed your JS bundle.

Vite font import to preload A four-step process diagram showing a font file import resolving to a fingerprinted build URL, then being referenced in both the @font-face rule and the preload link. Vite font import to preload 1 Import font file ?url suffix 2 Vite fingerprints content hash 3 Use in @font-face src: url() 4 Use in preload same hash
How an imported font file becomes a hashed URL used by both CSS and preload.

Astro variant: fingerprinting in the template with a static import

Astro's asset pipeline resolves the same way, but because .astro files render server-side (or at build time for static output), you import the font directly in the frontmatter and reference it in a <link> tag inside <head> — no runtime DOM manipulation needed, and the preload tag lands in the initial HTML for free.

---
// src/layouts/BaseLayout.astro
import interRegularUrl from '../assets/fonts/inter-subset-regular.woff2';
import interBoldUrl from '../assets/fonts/inter-subset-bold.woff2';
---
<html lang="en">
<head>
  <link
    rel="preload"
    as="font"
    type="font/woff2"
    href={interRegularUrl}
    crossorigin
  />
  <style define:vars={{ interRegularUrl, interBoldUrl }}>
    @font-face {
      font-family: 'Inter';
      src: url(var(--interRegularUrl)) format('woff2');
      font-weight: 400;
      font-display: swap;
    }
    @font-face {
      font-family: 'Inter';
      src: url(var(--interBoldUrl)) format('woff2');
      font-weight: 700;
      font-display: swap;
    }
    body { font-family: 'Inter', system-ui, sans-serif; }
  </style>
</head>
<body>
  <slot />
</body>
</html>

This variant is the defensive/edge-case pattern for the common failure mode: a raw string path hard-coded in a <style> block (url('/fonts/inter.woff2')) that works against the source tree in dev but 404s once the build hashes the filename. By importing the font as a module and passing it through define:vars, the url() inside the <style> block always resolves to whatever hash the current build produced — there is no window where the CSS and the preload tag can point at different files, because both are derived from the same import. Note define:vars requires the CSS custom property to be wrapped in var(--name); Astro serializes the imported string as the property value automatically, and because the whole block is server-rendered, the preload <link> and the <style> tag both land in the HTML response the browser receives on the very first request — no flash of unpreloaded content while a client bundle loads and re-derives the URL.

For content-driven Astro sites where the layout is shared across many pages, centralize the font-URL imports in a single src/fonts.ts module (identical in shape to the Vite example above) and import from there in every layout, rather than repeating the asset import path in each .astro file — this keeps the hashed URL resolution in one place and avoids subtly different @font-face blocks per page.

Astro head render order A layered stack diagram showing the Astro document head containing the preload link above the font-face style block, both derived from the same imported asset. Astro head render order Frontmatter import resolves hash <link rel=preload> in initial HTML <style> font-face define:vars <body> content renders after
Preload link and font-face style both land in the server-rendered head.

Verification

After a production build, confirm the emitted filename actually matches what your CSS and preload tag reference — do not trust that "it worked in dev."

  1. Run vite build (or astro build) and inspect the manifest: Vite writes dist/.vite/manifest.json, and Astro writes an equivalent asset manifest under dist/_astro/. Grep it for your font's basename to confirm the hash Chrome will actually request.
  2. Serve the built output (vite preview or astro preview) and open DevTools → Network, filtering by Font. Confirm exactly one request per font weight — a second request for the same file with a different Cache-Control line, or an (anonymous) vs (no-cors) initiator mismatch, means the preload's crossorigin doesn't match the @font-face fetch and the browser is double-fetching.
  3. Check the response headers on the font request: confirm content-encoding: br (Brotli, the compression .woff2 already carries or your CDN adds — see How WOFF2 Brotli Compression Shrinks Fonts) and a cache-control with a long max-age plus immutable, since the hashed filename means the content behind that exact URL will never change.
  4. In the Performance panel, confirm the preload's initiator chain shows it starting during the preload-scanner phase (before the main script executes), not after — if it starts late, the <link> isn't in the initial HTML and you've fallen back to the JS-injected pattern, which loses a network round trip on a slow connection.
# quick manifest check without opening the browser
cat dist/.vite/manifest.json | grep -A2 "inter-subset-regular"

Common Pitfalls

  • Hard-coded /fonts/... paths in global CSS. If your font @font-face lives in a plain .css file that Vite doesn't process as a module, the path is never fingerprinted and never cache-busted — a font update silently serves the old bytes to every visitor with a warm cache until the max-age expires. Move the declaration into a component or a CSS file imported via @import url() that Vite's asset pipeline actually rewrites, or use the define:vars pattern above in Astro.
  • crossorigin present on the <link> but missing from the actual @font-face fetch mode. The browser's @font-face font fetch is always CORS-anonymous regardless of what you write in CSS, so if the preload omits crossorigin (or sets it to a mismatched value), you get two separate cache entries for the identical URL and pay for the download twice — confirmed by two rows for the same file in the Network panel.
  • Preloading every font weight on every page. Preload is a strong signal that competes with the LCP resource for bandwidth — preload only the weight used above the fold on that specific route, and let the browser's normal font fetch (still fast, off an immutable-cached CDN) handle the rest, per the workflow in Font Preloading & Resource Hints Guide.
  • Importing the raw font file into a JS module without ?url in Vite. Without the suffix, some Vite configurations will attempt to inline small assets as a base64 data URI below the assetsInlineLimit threshold (4 KiB by default) instead of emitting a fingerprinted file — a font under that size ends up bloating your JS bundle and defeating the browser's separate font cache entirely. Explicitly append ?url or raise assetsInlineLimit awareness in your Vite config so fonts are never inlined.
  • Forgetting to re-verify after a dependency or asset-pipeline upgrade. Vite and Astro both occasionally change hashing algorithms or manifest formats between major versions; a stale build script that greps the manifest with an old key format silently stops finding the hash and falls back to an unhashed guess, reintroducing the exact drift this pattern exists to prevent.

Frequently Asked Questions

Does Astro's define:vars add runtime JavaScript overhead? No — define:vars is resolved entirely at build/render time. Astro serializes the value into the CSS custom property directly in the server-rendered <style> tag; no client-side JavaScript runs to set it, so it carries zero hydration cost even on an Astro island-heavy page.

Can I use this pattern with a font served from a CDN instead of bundled locally? Only partially. The asset-import fingerprinting described here applies to fonts that live in your repository and get processed by the bundler. If you're pulling from a third-party CDN or your own edge network instead, you don't get an import-time hash — you rely on the CDN's own cache-busting scheme, and the CORS considerations shift to matching the CDN's Access-Control-Allow-Origin header rather than Vite/Astro's manifest; see Font CDN & Edge Delivery for that variant.

What happens if I import a font in a component that Astro doesn't render on every page? The import still resolves to the correct hashed URL, but the @font-face rule only reaches pages that render that component (or the shared layout that imports it). If two components on the same page independently import and declare the same font family with different @font-face blocks, the browser deduplicates by matching font-family + font-weight + src, but you're better off centralizing the declaration in one shared layout to avoid maintaining duplicate blocks.

Should I still use next/font's automatic size-adjust behavior if I switch frameworks? There's no direct equivalent shipped by Vite or Astro, so you must compute your own fallback metric overrides manually — see Font Metrics & Baseline Alignment for the Web for how to derive ascent-override, descent-override, and size-adjust values by hand for your fallback stack, since Vite/Astro won't generate them for you the way next/font does.

Double-fetch check by crossorigin pairing A comparison table of crossorigin attribute pairings between the preload link and the font-face fetch, showing which combinations cause a duplicate download. Double-fetch check by crossorigin pairing @font-face fetch mode Result crossorigin=anonymous anonymous 1 request crossorigin missing anonymous 2 requests crossorigin=use-credentia… anonymous 2 requests
Whether preload and @font-face fetches share one cache entry.

Related