Nuxt Fonts v1.0.0

nuxt/fonts
Share
Summary
Nuxt Fonts v1.0.0 marks its first stable release, integrating the latest versions of `unifont`, `fontaine`, and `fontless`. Key updates include caching font metadata per project, enforcing build failures on font errors, and improved handling of `@font-face` descriptors. Additionally, fonts are now served from `/_nuxt/fonts` in Vite builds, with support for CDN URLs and enhanced configuration options.

Full release notes

v1.0.0 is the first stable release of Nuxt Fonts.

Nuxt Fonts has been in production use for over a year, and the underlying stack it is built on has now stabilised: unifont, fontaine and fontless all reached v1 alongside this release.

  • unifont resolves font metadata from providers
  • fontaine uses that metadata to cut layout shift
  • fontless does the whole thing in Vite with zero config

… and of course, @nuxt/fonts wraps all three and wires them into Nuxt.

Most projects should be able to upgrade simply by bumping the version, but read on for full details...!

🚨 Breaking (or significant) changes

Font metadata is cached per project

Font metadata and downloaded font files are now cached in node_modules/.cache/nuxt/fonts/meta relative to your project root rather than to the directory you run Nuxt from. The first build after upgrading will re-resolve and re-download fonts.

You can now configure the location with the cache option, pass your own unstorage instance, or disable persistent caching with cache: false.

export default defineNuxtConfig({
  fonts: {
    cache: '.cache/fonts',
  },
})

Font failures fail production builds

throwOnError now defaults to true outside of dev mode. Rather than producing a build with a missing font, the build fails when a provider errors, when a font file cannot be downloaded after retries, or when you set provider on a family and that provider does not contain it.

A family that cannot be found by any provider still warns rather than failing, as does an unknown provider name, so fonts you declare yourself in CSS are unaffected.

export default defineNuxtConfig({
  fonts: {
    throwOnError: false,
  },
})

Family-level @font-face descriptors now apply to provider fonts

display and unicodeRange set on a family were previously only honoured for families you declared manually with src, and were silently dropped for fonts resolved from a provider. They now apply in both cases. Descriptors other than display, weight and style (such as stretch, featureSettings and variationSettings) are also no longer dropped from manually declared families.

If you set any of these options on a family and relied on them being ignored, your generated CSS will change. Setting unicodeRange on a family marks it as subsetted, so it is no longer preloaded by default; set preload explicitly if you still want a preload link for it.

Injected @font-face rules are minified with lightningcss

Generated @font-face declarations were previously minified with esbuild unless you had opted into css.lightningcss. They are now always minified with lightningcss, so the exact serialisation of the CSS we inject may differ (for example local(Font Name) rather than local("Font Name")). This is cosmetic, but it will show up in snapshot tests.

The default 400 700 weight range is applied

weights has always been documented as defaulting to ['400 700'], but the default was set on the wrong option and never reached the resolver, so families without an explicit weights resolved at weight 400 alone. The documented default now takes effect (#619).

A family you didn't set weights on will resolve a bold face as well as a regular one, so expect additional @font-face rules and an additional file downloaded per family. Text that silently fell back to a synthesised bold will now render in the real one.

export default defineNuxtConfig({
  fonts: {
    defaults: {
      weights: [400],
    },
  },
})

Fonts are served from /_nuxt/fonts in Vite builds

Production builds with the Vite builder now emit font files as Vite build assets under app.buildAssetsDir, so they are served from /_nuxt/fonts/<hash>.woff2 rather than /_fonts/<hash>.woff2. Vite owns their URLs, which means app.cdnURL, a relative app.baseURL and experimental.renderBuiltUrl all now apply to fonts as they do to every other built asset.

The location of a font file is an implementation detail rather than a stable URL contract. If you have hard-coded /_fonts/ anywhere (a CDN rule, a CSP directive, a cache header, a test), point it at your build assets directory instead. Fonts are still served from /_fonts in development and with the webpack and rspack builders.

app.cdnURL now applies to fonts

This follows from the change above. cdnURL was previously never applied to font URLs, so fonts were always served same-origin. They now come from your CDN.

[!IMPORTANT]
Browsers always fetch fonts in CORS mode, so the CDN must respond with an Access-Control-Allow-Origin header that covers your site. Without it, fonts that previously loaded fail silently and the browser falls back to the next family in the stack. If you set cdnURL and don't control the headers on it, check this before upgrading.

assets.prefix is relative to buildAssetsDir in Vite builds

assets.prefix now names a directory inside app.buildAssetsDir in Vite production builds, and defaults to fonts. Setting prefix: '/my-fonts' serves fonts from /_nuxt/my-fonts rather than from /my-fonts. It remains a public path in development and with the webpack and rspack builders.

global: true families get font fallback metrics

global is the escape hatch for fonts we can't detect from your CSS, but until now that meant the fonts most likely to cause layout shift were the ones we did least about: the @font-face went into a global stylesheet and no fallback metric overrides were generated at all.

We now generate those overrides at every usage site we can see, so font-family: 'Anton' in a component becomes font-family: 'Anton', 'Anton Fallback: Arial', ... with the matching size-adjust and ascent-override rules alongside it, exactly as it would for a family that wasn't declared globally. The @font-face rule itself still comes from the global stylesheet and isn't duplicated, and preload hints are unchanged. Usage we can't scan (an SVG, a family chosen at runtime) keeps working as before.

Set fallbacks: [] on the family if you don't want the fallback rules.

The local provider only scans layer public/ directories

The local provider previously scanned every directory in nitro.publicAssets, including those that modules register for their own files. It now scans only the public/ directory of your project and of each layer.

If you relied on fonts in another nitro.publicAssets directory, add it to local.dirs:

export default defineNuxtConfig({
  fonts: {
    local: {
      dirs: ['../some-module/fonts'],
    },
  },
})

⚠️ Deprecations

experimental.processCSSVariables

fonts.experimental.processCSSVariables has moved to fonts.processCSSVariables. The old location still works and now warns.

export default defineNuxtConfig({
  fonts: {
    processCSSVariables: true,
  },
})

fonts:public-asset-context

Use fonts:resolved instead. It reports each family as it is resolved, with the URL every font file is served from and a readFont() to read its contents, so there's no need to reconstruct paths from the asset context. (The old hook still works.)

✨ Features

A new DevTools panel

The Fonts panel in Nuxt DevTools has been rebuilt on fontless. It lists every family resolved for your app, the provider that served it, the faces generated for it, where each one is used, and any warnings raised while resolving it.

https://github.com/user-attachments/assets/287f08a8-f92e-4a3d-a585-ab4ea80432db

webpack and rspack support

Nuxt Fonts now works with the webpack and rspack builders. Previously our CSS transform ran after css-loader had already turned stylesheets into JavaScript modules, so no @font-face rule was ever injected, and a family declared with global: true failed the client build outright.

The transform now runs immediately before css-loader, which means it sees your styles after Sass and PostCSS have run, so a family named only through a preprocessor variable is detected too. There's nothing to change on your side, but a build that silently shipped no web fonts will now download them and serve them from /_fonts.

Arbitrary variable axes

Variable font axes beyond wght now resolve from providers that publish them, so you can ask for wdth, slnt, opsz and more.

export default defineNuxtConfig({
  fonts: {
    families: [
      {
        name: 'Roboto Flex',
        provider: 'google',
        providerOptions: {
          google: {
            experimental: {
              variableAxis: { wdth: [['75', '100']] },
            },
          },
        },
      },
    ],
  },
})

Subsetting fonts to the glyphs you use

The new glyphs option reduces every font file we emit to the characters needed to render the text you give it, which can dramatically cut the bytes shipped for icon fonts and single-language sites.

export default defineNuxtConfig({
  fonts: {
    defaults: {
      glyphs: 'Handgloves & 0123',
    },
  },
})

Where a provider can subset server-side the characters are passed through to it, so the full file is never downloaded. Every other file is subsetted after download, which needs the subset-font package; we'll offer to install it the first time you run Nuxt with glyphs set.

[!IMPORTANT]
Before enabling subsetting, make sure the font licence allows you to do so. Many do not.

Preloading by subset

preload (on defaults and on individual families) accepts { subsets: [...] } or a filter function, so you can preload just the subsets your app needs.

export default defineNuxtConfig({
  fonts: {
    defaults: {
      preload: { subsets: ['latin'] },
    },
  },
})

Named font weights

weights accepts CSS keywords (normal, bold, light, ...) in config, and the local provider recognises them in filenames, so MyFont-Bold.woff2 is picked up as weight 700.

Scanning additional local font directories

The local provider can scan directories beyond public/, and now reports which filenames it looked for when a family cannot be resolved, which makes misnamed files much easier to debug.

export default defineNuxtConfig({
  fonts: {
    local: {
      paths: ['assets/fonts'],
    },
  },
})

Reuse resolved fonts from another module

Modules that need the fonts your app already uses - for example, to render an OG image or a PDF - had to reverse-engineer internal build machinery to find them, then re-resolve and re-download the same families themselves.

The new fonts:resolved hook reports each family as it is resolved, with the URL every font file is served from and a readFont() to read its contents, so nothing has to be resolved or downloaded more than once.

const files = new Map<string, () => Promise<Buffer>>()

nuxt.hook('fonts:resolved', (font) => {
  for (const file of font.files) {
    files.set(file.url, file.readFont)
  }
})

Listeners run while your stylesheets are transformed, so collect what you need and call readFont() later. See the hook documentation.

Custom CSS variable prefixes

processCSSVariables accepts a custom prefix, so processCSSVariables: 'my-app' processes --my-app-* variables only.

🩹 Fixes

  • Font asset URLs respect the app baseURL and a custom cdnURL
  • Inlined @font-face rules are deduplicated in the rendered HTML
  • Font downloads are retried before a build is failed
  • Local variable fonts with weight ranges resolve correctly
  • Fallback metrics are generated for fonts in the public directory
  • Nitro v3 and Nuxt 5 compatibility
  • Devtools UI works under a custom baseURL and with @nuxt/devtools v4
  • Deprecated experimental.processCSSVariables now warns rather than being silently ignored
  • Dev font server errors are passed to the next middleware instead of hanging the request
  • Multiple families entries sharing a name are all applied
  • The local provider detects camel-cased variable font markers in filenames
  • Font faces injected into bundled CSS resolve their URLs correctly
  • Hoisted global font faces are minified
  • Fallbacks are not regenerated for already-transformed declarations
  • The local provider no longer scans nitro.publicAssets directories registered by modules

🙏 Thank you

Thank you so much to everyone who has contributed to @nuxt/fonts, unifont, fontaine and fontless to get us here. ❤️

🚀 How to upgrade

After upgrading to the latest version, clear your font cache and rebuild:

rm -rf node_modules/.cache/nuxt/fonts

Please read the upgrade guide for the full detail, and let me know how you get on! 🙏

👉 Changelog

compare changes

🚀 Enhancements

  • use devframe-based devtools from fontless (#937)
  • add fonts:resolved hook (#921)
  • ⚠️ serve fonts as vite build assets under buildAssetsDir (#918)
  • generate fallback metrics for global families where used (#915)
  • inject font faces under the webpack and rspack builders (#914)
  • support resolving variable font axes (#909)
  • local: accept named font weights in config and filename aliases (136ac36)
  • deps: update fontless to 0.4.3 (#421)
  • subset downloaded fonts to the glyphs a family declares (#131)
  • local: report filenames looked for when a family can't be resolved (#895)
  • npm: resolve installed font packages with resolve + exists hooks (#894)
  • ⚠️ deps: update fontless and unifont (#887)
  • local: support scanning font files from additional directories (#876)
  • configurable font cache (#869)
  • ⚠️ deps: update unifont, fontaine and fontless (#868)

🩹 Fixes

  • resolve urls of font faces injected into bundled css (#939)
  • minify hoisted global font faces (6103883)
  • don't regenerate fallbacks for already-transformed declarations (60d7904)
  • local: only scan layer public/ directories (#938)
  • support multiple families entries with the same name (d7987a8)
  • local: detect camel-cased variable font markers (#931)
  • use explicit file extensions in relative imports (#930)
  • local: scan for font files during module setup (#929)
  • use fontless preload selection (#920)
  • fail before downloading fonts that need a missing subset-font (00436e1)
  • respect user weights if provided (8b6d7f0)
  • apply the default 400 700 weight range (#619)
  • local: generate fallback metrics for public directory fonts (#171)
  • nitro v3 compatibility (#898)
  • warn when deprecated experimental.processCSSVariables is set (#892)
  • pass dev font server errors to the next middleware (#891)
  • deduplicate inlined @font-face rules in rendered html (#879)
  • retry font downloads and fail production builds on font errors (#877)
  • support local variable fonts with weight ranges (#794)
  • inline font faces from global stylesheets (#875)
  • prefix font asset URLs with app baseURL (1f13e79)
  • respect baseURL when serving devtools UI (918d036)
  • consider global fonts for preloading as well (#856)
  • devtools: use hook for compat with devtools v4 (05ae60c)
  • strip style queries when tracking fonts to preload (#872)
  • pass a real H3Event in storybook mode (#807)

💅 Refactors

  • let fontless fill per-category fallback defaults (#893)
  • client: migrate away from devtools-ui-kit (ad5b781)

📖 Documentation

  • add docs for devtools panel and fonts:resolved (c9478ca)
  • document variable font usage and naming (#933)
  • note that non-default subsets need configuring (#932)
  • cover unicode ranges in glyphs and provider failure isolation (4b7a21b)
  • correct some inaccuracies in the docs (823edb0)
  • refresh the docs site for v1 (#919)
  • note that lightningcss minification needs the optional peer (8928fb0)
  • update upgrade guide (5f41ed3)
  • document global as the escape hatch for undetectable font usage (#917)
  • drop mention of tailwindcss v3 (b764025)

🏡 Chore

  • fix typecheck against the module stub (99bb608)
  • pin nuxt 5 playground (7a46fde)
  • client: don't overwrite built client when running playground (bb36013)
  • exempt first-party packages from pnpm minimumReleaseAge (#862)
  • replace latest ranges with caret ranges (#861)
  • migrate to pnpm v11 (#831)
  • migrate npm badges and links to npmx.dev (a5c61fb)

✅ Tests

  • tolerate per-file font face duplication in bundled css (a5a4dd9)
  • clear the font cache of every fixture and playground (7995bd1)
  • cover tailwind v4 --default-font-family theme variables (#890)
  • cover family-level font-face descriptor overrides (#888)
  • cover font URLs in inlined styles with a custom cdnURL (#880)
  • drop tailwindcss v3 (d7921fb)
  • use human readable font names in snapshots (d119be1)
  • refresh snapshots + make resilient to vite 8 changes (#857)
  • update test assertions for vite 8 (16e87f6)
  • mock adobe api responses (#819)

🤖 CI

  • use pnpm/setup and devEngines (#907)
  • adopt uppt for release workflow (#866)
  • run ci for merge groups (8c3dd7f)
  • migrate agentscan-action to v2 (7dd7a38)
  • add agent-scan workflow to flag bot-authored PRs (349b7ba)
  • rename workflow (8154aa6)
  • avoid checkout for reproduction comment (ed921dc)

🎉 New Contributors

  • Peter Uithoven (@peteruithoven)
  • Meliceanu (@Meliceanu)
  • Cynthia Rey (@cyyynthia)

❤️ Contributors

  • Daniel Roe (@danielroe)
  • Harlan Wilton (@harlan-zw)
  • Peter Uithoven (@peteruithoven)
  • Meliceanu (@Meliceanu)
  • Cynthia Rey (@cyyynthia)

Latest Nuxt Fonts releases

All releases