Nuxt Fonts v1.0.0
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.
unifontresolves font metadata from providersfontaineuses that metadata to cut layout shiftfontlessdoes 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 anAccess-Control-Allow-Originheader 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 setcdnURLand 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
baseURLand a customcdnURL - Inlined
@font-facerules 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
baseURLand with@nuxt/devtoolsv4 - Deprecated
experimental.processCSSVariablesnow warns rather than being silently ignored - Dev font server errors are passed to the next middleware instead of hanging the request
- Multiple
familiesentries sharing a name are all applied - The
localprovider 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
localprovider no longer scansnitro.publicAssetsdirectories 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
🚀 Enhancements
- use devframe-based devtools from
fontless(#937) - add
fonts:resolvedhook (#921) - ⚠️ serve fonts as vite build assets under
buildAssetsDir(#918) - generate fallback metrics for
globalfamilies 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
fontlessto0.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+existshooks (#894) - ⚠️ deps: update
fontlessandunifont(#887) - local: support scanning font files from additional directories (#876)
- configurable font cache (#869)
- ⚠️ deps: update
unifont,fontaineandfontless(#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
familiesentries 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
fontlesspreload selection (#920) - fail before downloading fonts that need a missing
subset-font(00436e1) - respect user weights if provided (
8b6d7f0) - apply the default
400 700weight range (#619) - local: generate fallback metrics for public directory fonts (#171)
- nitro v3 compatibility (#898)
- warn when deprecated
experimental.processCSSVariablesis set (#892) - pass dev font server errors to the next middleware (#891)
- deduplicate inlined
@font-facerules 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
baseURLwhen 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
fontlessfill 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
glyphsand 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
globalas 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
latestranges 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-familytheme 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/setupanddevEngines(#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)
