Loading Web Fonts in JavaScript Frameworks

This guide is part of the Font Loading & Delivery Strategies area.

Every modern JavaScript framework now ships some opinion about how fonts should reach the browser. That opinion ranges from "here's a bundler that hashes your asset filenames and otherwise stays out of your way" (Vite, Astro) to "here's a build-time module that rewrites your @font-face rules, computes fallback metrics, and injects preload tags for you" (Next.js's next/font, Nuxt's font module). The problem this guide solves is not "how do fonts work" — that's covered in font-display values and the CSS Font Loading API guide — it's "where in a framework's build pipeline do I hook in font optimization, and which parts does the framework already do for me." Get this wrong and you end up either fighting the framework (manually preloading a font next/font already preloaded, doubling the request) or leaving free wins on the table (shipping the default Vite asset pipeline with no preload, no size-adjust, and a render-blocking @font-face declaration).

The stakes are measurable. On a cold 4G load, a page that pulls fonts from fonts.googleapis.com with no resource hints typically lands Largest Contentful Paint (LCP) around 3.1s when body text is the LCP element. The same font, self-hosted with the framework's default asset pipeline and no preload, lands closer to 2.2s. Self-hosted, preloaded, and metric-matched with a fallback — which is what next/font gives you by default — lands around 1.4s. That 1.7-second spread is entirely attributable to how the framework moves the font from your source tree to the browser's font cache.

Framework font handling compared Comparison matrix of font handling features across Next.js, Vite, Astro, and Nuxt. Framework font handling compared Next.js Vite Astro Nuxt Auto self-host Yes Manual Manual Module Auto preload Yes No No Module size-adjust fallba… Yes No No Yes Build-time hashing Yes Yes Yes Yes
Self-hosting, preload injection, and metric fallbacks across four build tools.

Baseline configuration

Before comparing frameworks, establish what "correct" looks like independent of any build tool. A framework-agnostic baseline self-hosts the font, serves WOFF2 with a content hash in the filename, preloads the critical weight, and declares font-display: swap:

@font-face {
  font-family: "Inter";
  src: url("/assets/inter-v13-latin-600.a1b2c3d4.woff2") format("woff2");
  font-weight: 600;
  font-style: normal;
  font-display: swap;
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+2000-206F;
}
<link
  rel="preload"
  href="/assets/inter-v13-latin-600.a1b2c3d4.woff2"
  as="font"
  type="font/woff2"
  crossorigin
/>

Every framework-specific font tool is, at its core, an automated way to generate these two blocks — the hashed @font-face rule and the matching preload tag — without you hand-maintaining filenames across builds. The differences are in what else gets automated: fallback metric calculation, subset selection, and whether the tool self-hosts third-party fonts (like Google Fonts) for you.

Step-by-step workflow

  1. Inventory the fonts you actually ship. Count distinct family/weight/style combinations. Each is a separate HTTP request and a separate entry in your @font-face table. Most sites can cut this to 2–3 weights (regular, bold, and maybe one italic) before touching any tooling.
  2. Pick self-hosting over a third-party CDN link unless you have a specific reason not to — see the trade-offs in Google Fonts vs. Self-Hosting. Every framework in this guide can self-host; none require you to keep the fonts.googleapis.com <link>.
  3. Let the framework's asset pipeline hash the file. Vite, Astro, and Next.js all fingerprint imported binary assets by content hash, which is what makes the Cache-Control: immutable pattern safe — the URL changes when the bytes change, so a one-year cache lifetime never serves stale bytes.
  4. Generate or verify the preload tag lands in the document <head> before the first paint. next/font does this automatically for any font used in a Server Component; in Vite/Astro you add it yourself in the layout template.
  5. Compute a fallback metrics override (size-adjust, ascent-override, descent-override) so the fallback system font occupies the same box as the web font, preventing layout shift during the swap — next/font and Nuxt's font module do this automatically; see Font Metrics & Baseline Alignment for the manual calculation.
  6. Verify in production, not dev mode. Dev servers frequently serve fonts unhashed and uncompressed; only a production build reflects the real request waterfall.
Framework font pipeline Five-step process a framework font pipeline follows from raw font file to rendered page. Framework font pipeline 1 Import font file local or package 2 Hash + emit asset content hash 3 Generate @font-face size-adjust 4 Inject preload link critical weight 5 Render with fallback font-display: swap
The build-time path from source font to a preloaded, cache-busted asset.

Framework-by-framework behaviour

Next.js: next/font

next/font is a build-time module, not a runtime library — it runs during next build (and in the dev server) and outputs static CSS plus self-hosted font files. It works for both Google Fonts and local files:

// app/layout.js
import { Inter } from "next/font/google";
import localFont from "next/font/local";

const inter = Inter({
  subsets: ["latin"],
  weight: ["400", "600"],
  display: "swap",
  variable: "--font-inter",
});

const brand = localFont({
  src: "./fonts/brand-variable.woff2",
  weight: "300 800",
  display: "swap",
  variable: "--font-brand",
});

export default function RootLayout({ children }) {
  return (
    <html lang="en" className={`${inter.variable} ${brand.variable}`}>
      <body>{children}</body>
    </html>
  );
}

When you request Inter from next/font/google, Next.js downloads the font at build time, self-hosts it from your own domain (no runtime request to Google ever happens, and no fonts.googleapis.com origin appears in the network panel), computes a fallback font's size-adjust/ascent-override/descent-override triplet from the target font's metrics, and injects a <link rel="preload"> for any weight actually rendered above the fold. This is covered in full, including the adjustFontFallback option and the CSS custom-property pattern, in Next.js Font Optimization with next/font.

Vite and Astro: bundler-level asset handling

Vite (and Astro, which uses Vite under the hood) does not ship a font-specific module — it treats a .woff2 file the same as any other static asset: import it, get back a hashed URL string, use that string in your CSS.

// vite / astro — importing a font asset
import interRegularUrl from "./fonts/inter-v13-latin-400.woff2?url";
@font-face {
  font-family: "Inter";
  src: url("/assets/inter-v13-latin-400.BbGzQ8k1.woff2") format("woff2");
  font-weight: 400;
  font-display: swap;
}

The hashing, cache-busting, and CDN-ready output all happen — but preload injection, subsetting, and fallback metric calculation are entirely on you. That's not a deficiency; it's a smaller surface area with fewer surprises, at the cost of more manual wiring. The exact patterns for both frameworks — where to put the <link rel="preload"> in an Astro layout, and how Vite's ?url suffix differs from a plain import — are in Self-Hosting Fonts in Vite and Astro.

Nuxt: @nuxt/fonts

Nuxt's font module sits between the two extremes: it scans your CSS for font-family usage, resolves the font from a provider (Google Fonts, Fontsource, or local files), self-hosts it, and generates @font-face with fallback metrics — similar in spirit to next/font, but driven by a Nuxt module rather than an import in application code:

// nuxt.config.ts
export default defineNuxtConfig({
  modules: ["@nuxt/fonts"],
  fonts: {
    defaults: {
      weights: [400, 600],
      styles: ["normal"],
      subsets: ["latin"],
    },
  },
});

You write ordinary CSS (font-family: "Inter", sans-serif), and the module rewrites the build output to self-host and preload it. This scan-based approach means there's no explicit import to point at in your component code — the trade-off is less explicit control per-font, in exchange for zero markup changes.

Decision table

Concern Next.js (next/font) Vite / Astro (raw asset import) Nuxt (@nuxt/fonts)
Self-hosts Google Fonts automatically Yes No — you download and import manually Yes
Injects <link rel="preload"> Yes, automatic Manual, you add it Yes, automatic
Computes size-adjust fallback Yes Manual, use Capsize or manual math Yes
Subsetting control Per-subset option, no custom glyph sets Full manual control via pyftsubset Per-subset option
Works with variable fonts Yes, weight: "300 800" range Yes, manual @font-face Yes
Build-time network calls Yes (fetches Google Fonts at build) No Yes

Common Pitfalls

  • Double preloading. Manually adding a <link rel="preload"> for a font that next/font or @nuxt/fonts already preloads produces two requests for the same URL in older browsers that don't dedupe preload-then-fetch aggressively, and always adds a redundant entry to the resource timeline that confuses RUM data. Check the rendered <head> output before adding your own hint.
  • Importing a font file without ?url in Vite, which inlines small assets as a base64 data URI instead of emitting a hashed file — this defeats caching entirely and bloats your CSS bundle. Vite's default assetsInlineLimit is 4096 bytes; most subsetted WOFF2 weights are smaller than that, so they get inlined unless you force the URL import or raise the threshold for font extensions.
  • Fetching every weight and style the framework's Google Fonts wrapper offers "just in case." next/font/google will happily download 9 weights times 2 styles if you list them all in the weight array — each one is a build-time network fetch and a static asset shipped to every visitor. Constrain weight and subsets to what the design system actually uses.
  • Skipping the fallback metrics step on Vite/Astro because it's not automatic there. Without a computed size-adjust, the fallback and web font occupy different vertical space, and the swap at font-display: swap's swap period causes visible content jumps — the exact failure mode covered in Accessible Fallback Font Stacks & CLS Prevention.
  • Trusting dev-mode network behaviour. Next.js dev mode and Vite's dev server both serve fonts differently than the production build (less aggressive caching, sometimes unhashed URLs) — always verify preload and cache headers against next build && next start or vite build && vite preview.
  • Leaving next/font's default display: "optional" in place for body text — that default favors zero layout shift over guaranteed text visibility, which is right for decorative type but can leave slow-network users staring at invisible body copy. Set display: "swap" explicitly for text the user needs to read immediately; see font-display: swap vs optional for the full trade-off.
LCP by font delivery method Bar chart comparing LCP for Google Fonts CDN link, unoptimised self-hosting, and framework-optimised self-hosting with preload. LCP by font delivery method Google Fonts link 3.1s Self-host, no preload 2.2s next/font + preload 1.4s seconds
Largest Contentful Paint across three common framework setups on a cold 4G load.

A closer look at the Next.js pipeline

It's worth understanding what next/font actually does under the hood, because the mental model transfers to every other build-time font tool you'll encounter. When the compiler sees Inter({ subsets: ["latin"], weight: ["400","600"] }), it does the following at build time, not at request time:

  1. Resolves the specific font files for the requested weights and subset from Google's font metadata (the same metadata fonts.googleapis.com itself serves from, just fetched once during your build rather than per visitor).
  2. Downloads those files and writes them into your build output under a content-hashed path.
  3. Parses the font's hhea/OS/2 metrics table to compute a metrics-compatible fallback — typically Arial or a platform default — with size-adjust, ascent-override, descent-override, and line-gap-override tuned so the fallback's line box matches the web font's line box almost exactly.
  4. Emits a single @font-face rule referencing the hashed local file, plus a second @font-face rule for the synthetic fallback family.
  5. Wires the className/variable you use in JSX to a CSS class that sets font-family to the real font, with the synthetic fallback listed second, and a generic family third.
  6. If the font is referenced by a component that renders in the initial HTML (not lazily), adds a <link rel="preload" as="font" crossorigin> to the document <head> for the resolved file.

That six-step sequence is the same shape as the manual workflow in the "Baseline configuration" section above — the only difference is who performs each step. Reading it this way makes it straightforward to reason about what breaks when you deviate: skip step 3 (as raw Vite/Astro imports do) and you reintroduce layout shift on font swap; skip step 6 and you lose the LCP improvement shown in the bar chart above; get step 1 wrong (request a weight the font doesn't actually have, like 750) and the build either fails or silently falls back to the nearest available weight, which is worth checking for in your build logs.

One detail that trips teams migrating from a <link href="https://fonts.googleapis.com/..."> tag: next/font/google requires the font family and weight/style combination to exist in Google's catalog exactly as named. A typo like Inter Tight vs InterTight produces a build-time error rather than a silently broken runtime request, which is a net win for catching mistakes early, but the error message names the field, not the fix — cross-reference the family name against the catalog before assuming the module is broken.

Astro and Vite in practice: a complete example

Because Vite and Astro don't automate preload injection or fallback metrics, here's the fuller pattern that fills the gap, combining a hashed asset import with a manually computed fallback and an explicit preload tag in the document head:

---
// src/layouts/BaseLayout.astro
import interBoldUrl from "../fonts/inter-v13-latin-600.woff2?url";
---
<html lang="en">
  <head>
    <link
      rel="preload"
      href={interBoldUrl}
      as="font"
      type="font/woff2"
      crossorigin
    />
    <style is:global>
      @font-face {
        font-family: "Inter";
        src: url("/fonts/inter-v13-latin-600.woff2") format("woff2");
        font-weight: 600;
        font-display: swap;
      }
      @font-face {
        font-family: "Inter Fallback";
        size-adjust: 107%;
        ascent-override: 90%;
        src: local("Arial");
      }
      body {
        font-family: "Inter", "Inter Fallback", sans-serif;
      }
    </style>
  </head>
  <body><slot /></body>
</html>

The size-adjust: 107% and ascent-override: 90% values here aren't arbitrary — they come from comparing Inter's font metrics against Arial's using a tool like Capsize or the fonttools ttx dump of the OS/2 table, exactly as described in Using Capsize to Crop Font Metrics. This is the manual equivalent of what next/font computes automatically, and it's the single biggest gap between the "batteries included" and "bring your own wiring" approaches.

For a Vite SPA without Astro's templating layer, the same idea moves into your entry HTML and a CSS module:

// main.js
import "./fonts.css";
import interRegularUrl from "./fonts/inter-v13-latin-400.woff2?url";

// Inject preload programmatically if you can't edit index.html directly
const link = document.createElement("link");
link.rel = "preload";
link.as = "font";
link.type = "font/woff2";
link.crossOrigin = "anonymous";
link.href = interRegularUrl;
document.head.appendChild(link);

Injecting the preload link with JavaScript after the module graph resolves is strictly worse than a static <link> in index.html, because the browser's preload scanner — which starts parsing before any JavaScript executes — never sees a <script>-inserted tag during its first pass. Prefer writing the hashed URL into a static HTML template at build time (Vite's transformIndexHtml plugin hook, or an Astro/Eleventy/Nunjucks template variable) whenever the build tool gives you that option; reserve the JavaScript-injection pattern for genuinely dynamic cases, like a font chosen at runtime from a user preference.

Server-rendered hydration and the flash-of-unstyled-text window

A subtlety specific to frameworks with server-side rendering (Next.js App Router, Nuxt, Astro in SSR mode): the server emits the initial HTML with the @font-face rules already in the <head>, but the browser still has to fetch, parse, and swap the font exactly as it would on a static site — SSR does not make font loading synchronous. What SSR changes is that there's no client-side "font missing" flash from a framework mounting late, because the CSS (and, with a framework font module, the preload hint) is present in the first HTML response rather than being injected after hydration. If you see FOUT/FOIT symptoms specifically worsen after adopting SSR, the more likely culprit is that the framework's streaming or partial-hydration boundaries delay when the <head> itself is flushed to the client — check the Network panel's timing for the document response itself, not just the font request, per the walkthrough in Measuring Font Loading Performance.

Frequently Asked Questions

Does next/font still make a network request to Google at runtime? No. The download happens once, at build time (or the first time the dev server compiles the route), and the resulting WOFF2 file is copied into your own _next/static output. Every subsequent page load fetches the font from your domain, with no fonts.googleapis.com request at any point — verify this by filtering the Network panel to google while loading a production build.

Can I use unicode-range subsetting alongside a framework's font module? With next/font/google, the subsets option already does this — passing subsets: ["latin"] restricts the emitted @font-face blocks to the Latin unicode-range, and the module ships only that range's bytes. For local fonts (next/font/local) and for Vite/Astro's raw asset imports, you must subset the file yourself before import, typically with pyftsubset — see How unicode-range Reduces Font Payload for the exact command.

Do these frameworks handle variable fonts differently from static weights? next/font/local accepts a weight range string like "300 800" for a single variable font file and generates one @font-face block with that range, rather than one block per static weight file — this is the same mechanism described generally in Variable Font Loading Techniques. Vite/Astro handle variable fonts identically to any other font file: import it, write the @font-face with a font-weight: 300 800 range yourself.

Is it worth switching frameworks just for the font module? No — the manual pattern (hashed asset import, hand-written preload, computed size-adjust) achieves the same production result as next/font or @nuxt/fonts, just with more code you own. Choose a framework for its rendering model and ecosystem; treat the font module as a convenience that saves you from writing boilerplate you'd otherwise write once and reuse across every project.

What happens if I use both a framework font module and a manual @font-face for the same family? You end up with two competing @font-face declarations for the same font-family name, and CSS cascade rules — not the framework — decide which one wins based on specificity and source order, which is rarely what you intended. This is most common when a team migrates an existing site to next/font but forgets to delete the old hand-written @font-face block from a global stylesheet. Grep your CSS for the family name before adopting a framework module, and delete any pre-existing declaration for it; keep only the framework-generated one.

Do these font modules work inside a monorepo with a shared design-system package? Yes, but the self-hosting step has to happen in the app that actually ships to the browser, not in the shared package. A design-system package can export the font configuration (family name, weight list, subset list) as plain data, but each consuming app should call next/font/google or perform its own Vite asset import locally, so the hashed output lands in that app's own build directory and cache-control domain. Centralizing the actual font files in a shared package works too, as long as each app's bundler still processes them through its own asset pipeline rather than referencing a pre-built URL from another app's deploy.

Choosing between the automated and manual paths

The decision isn't really "which framework has the best font module" — it's "how much do I want the build tool deciding font behaviour on my behalf, versus how much do I want to control every byte myself." Three signals point toward preferring an automated module (next/font, @nuxt/fonts) over the manual Vite/Astro pattern:

  • Team size and font-change frequency. If designers swap font weights or add a new family every few sprints, an automated module removes an entire class of "did someone forget to update the preload tag" bugs, because the preload is derived from usage, not maintained by hand.
  • Google Fonts as the primary source. The manual pattern's biggest advantage — full control over subsetting via pyftsubset and glyphhanger, per glyphhanger vs pyftsubset — matters most when you're subsetting a custom or licensed font down to a bespoke glyph set. If you're using stock Google Fonts with the standard Latin subset, the automated module's built-in subsetting already gets you most of the win.
  • Need for exact fallback metric control. Automated fallback metrics are computed from the target font's own OS/2/hhea tables against a small set of known system fonts (Arial, Helvetica, and a couple of platform defaults). If your design system standardizes on an unusual fallback — say, a licensed system font not in that known set — you'll compute the size-adjust triplet yourself regardless of framework, using the same Capsize-based technique either way.

In practice, most production teams land on a hybrid: use the framework's automated module for the common case (body copy, headings, a couple of weights), and drop down to a manual @font-face plus hand-computed fallback for the rare exception (a licensed display face used in one hero section, or a font loaded conditionally behind a feature flag). Treat the automated module as the default, not a constraint you have to work around for every font on the page.

Related