Skip to content

Fonts

Self-hosted fonts from npm, and what next/font was doing for you.

Updated View as Markdown

Install the font, import its CSS, name it in a variable. Vite does the rest.

bun add @fontsource-variable/fraunces @fontsource-variable/geist
src/app/styles.csscss
@import '@fontsource-variable/fraunces/full.css';
@import '@fontsource-variable/fraunces/full-italic.css';
@import '@fontsource-variable/geist';

:root {
  --font-display: 'Fraunces Variable', ui-serif, Georgia, Cambria, 'Times New Roman', serif;
  --font-sans: 'Geist Variable', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
    'Helvetica Neue', Arial, sans-serif,
    'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
}

Put your font in front of a full stack rather than in place of one. Two things the rest of the stack does that a bare sans-serif does not:

  • Emoji. Geist has no emoji glyphs, so an emoji falls through to whatever is named after it. With nothing there, some Linux and older Android setups draw a monochrome glyph or a box. The four emoji fonts at the end are consulted only for characters the main font cannot draw, so they cost nothing.
  • A closer fallback. During the swap window the browser shows the next font. -apple-system and Segoe UI are nearer Geist in width and x-height than Helvetica or Arial, so the moment of swap is a smaller jolt.

Those are Tailwind’s own defaults, and shadcn’s generated --font-sans: 'Geist Variable', sans-serif drops both. Keep the stack.

Fontsource packages every Google font — and many others — as woff2 files with the @font-face rules already written. Vite hashes the files and serves them beside your other assets, so nothing is fetched from Google at runtime, and nothing is downloaded at build time either. The files are in node_modules.

Porting from next/font

// before
const fraunces = Fraunces({
  variable: '--font-display',
  subsets: ['latin'],
  style: ['normal', 'italic'],
  axes: ['SOFT', 'opsz'],
  display: 'swap',
})

Every option there is something Fontsource already did:

next/font Fontsource
variable: '--font-display' the :root rule above — you write the variable, once
subsets: ['latin'] every subset ships, each with a unicode-range; the browser fetches only the ones the page uses
style: ['normal', 'italic'] full.css and full-italic.css
axes: ['SOFT', 'opsz'] full.css — every axis of the variable font; standard.css is weight alone
display: 'swap' already in every rule
className={fraunces.variable} nothing — the variable is on :root

So the layout loses its font imports and the className gains nothing:

src/app/layout.tsxtsx
import './styles.css'

export default function RootLayout({ children }) {
  return (
    <html lang="en" className="h-full antialiased dark font-sans">

    </html>
  )
}

The subsetting is worth a second look, because it is better than what you had. next/font asks you to name subsets at build time and ships those. Fontsource ships all of them, each behind a unicode-range, and the browser downloads only the ones a page’s text actually needs — a Latin-only page fetches one file whatever else is installed.

Preloading

The one thing next/font did that needs a line here. It added a <link rel="preload"> for each font so the browser finds it before the stylesheet does. Without one, the font is discovered when the CSS is parsed, and the text shows in the fallback face for a moment longer.

Import the file for its url, and React hoists the link:

src/app/layout.tsxtsx
import fraunces from '@fontsource-variable/fraunces/files/fraunces-latin-full-normal.woff2?url'

export default function RootLayout({ children }) {
  return (
    <html>
      <head>
        <link rel="preload" href={fraunces} as="font" type="font/woff2" crossOrigin="anonymous" />
      </head>

?url is Vite’s — it hands back the hashed path the build will serve. Preload the one file the first paint needs, usually the Latin regular; preloading all of them defeats the subsetting.

A font that is not on npm

A file you own goes in public/ and gets its own @font-face in your CSS, the same as anywhere. Or in src/ and imported with ?url as above, which hashes it.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close