Type something to search...
Self-Hosting Web Fonts: A Step-by-Step Guide

Self-Hosting Web Fonts: A Step-by-Step Guide

Adding a font from a hosted service takes one <link> tag, which is why so many sites do it. But that tag pulls in a stylesheet from another domain, which then points the browser at font files on yet another domain. Each new origin needs its own DNS lookup, connection, and TLS handshake before a single byte of font data arrives. You also can't control the caching, the exact files served, or the request data a third party receives about your visitors.

Self-hosting means putting the font files on your own server or CDN and writing the @font-face rules yourself. It's more work up front, typically half an hour, but it gives you full control over what gets downloaded and when. Since browsers partitioned their HTTP caches by site, the old argument that a shared Google Fonts cache would already have the file no longer applies, so the performance case for self-hosting is stronger than it used to be.

This guide walks through the whole process: checking the license, getting the files, converting and organizing them, writing @font-face, preloading, caching, and verifying it all works.

Step 1: Check the License

Before you download anything, make sure the license allows self-hosting. This matters more than any technical step.

  • Open-source fonts under the SIL Open Font License (OFL) or Apache License, which covers most of the Google Fonts catalog, generally allow self-hosting and modification, including subsetting.
  • Commercial fonts usually require a specific web license. Some are priced by monthly page views, some forbid modification (which can include subsetting or format conversion), and some only permit use through the foundry's own hosting.
  • Fonts installed on your computer aren't automatically licensed for web use, even if they came with your operating system or design software.

Read the license file that ships with the font, and if anything is unclear, ask the foundry. This isn't legal advice; for anything commercial or ambiguous, check with the vendor or a lawyer. Understanding Font Licensing: Desktop, Web, and App Licenses Explained covers the license types in more depth.

Step 2: Get the Font Files

Where you get the files depends on the source:

  • The foundry or project's own release. Many open-source families publish releases on GitHub with web-ready WOFF2 files included. This is usually the best source, since you get the latest version and often a variable font.
  • Google Fonts. The family page lets you download the font, though the download typically contains TTF files rather than WOFF2.
  • Fontsource. The Fontsource project packages open-source fonts as npm packages with pre-subsetted WOFF2 files, which is convenient for JavaScript build setups.
  • A commercial foundry. After purchasing a web license, you'll get a web font kit, usually WOFF2 and sometimes WOFF.

Prefer a variable font if one exists and you need three or more weights. One file can replace a whole set of static weights. If you only need Regular and Bold, two static files may be smaller.

Step 3: Convert to WOFF2

Browsers can load TTF and OTF directly, but WOFF2 is compressed and much smaller. Every current browser supports it, so it's the only format most sites need. WOFF2 vs WOFF vs TTF vs OTF: Font Formats for the Web Explained explains the differences.

If your source files are TTF or OTF, convert them with fonttools, the same Python package used for subsetting:

pip install fonttools brotli

# Convert a single file to WOFF2
fonttools ttLib.woff2 compress SourceSerif4-Regular.ttf

# Output: SourceSerif4-Regular.woff2

Google's woff2_compress command-line tool does the same job if you prefer it.

Subset While You're at It

Most fonts include characters for languages your site never shows. Removing them often cuts file size dramatically. A Latin-only subset is a common choice for English-language sites:

pyftsubset SourceSerif4-Regular.ttf \
  --unicodes="U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD" \
  --layout-features="*" \
  --flavor=woff2 \
  --output-file=source-serif-4-latin-400.woff2

Subsetting has trade-offs, such as dropped OpenType features or missing characters in user-generated content. Font Subsetting: Shrinking Web Font Files Without Losing Characters covers how to do it safely.

Step 4: Organize the Files

Pick a predictable folder and naming scheme. A pattern that includes family, subset, weight, and style makes @font-face rules easy to read and avoids mix-ups:

public/
  fonts/
    inter-latin-var.woff2
    inter-latin-var-italic.woff2
    source-serif-4-latin-400.woff2
    source-serif-4-latin-400-italic.woff2
    source-serif-4-latin-700.woff2

Lowercase, hyphenated names avoid case-sensitivity surprises when you deploy from macOS (case-insensitive by default) to a Linux server (case-sensitive).

Commit the license file alongside the fonts, for example public/fonts/OFL.txt. Many open-source licenses require that the license accompany the font when you redistribute it, and serving it from your site counts as distribution.

Step 5: Write the @font-face Rules

Each file gets one @font-face rule. Rules with the same font-family name form a family, and the browser picks the right file based on font-weight and font-style.

Static Fonts

@font-face {
  font-family: "Source Serif 4";
  src: url("/fonts/source-serif-4-latin-400.woff2") format("woff2");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: "Source Serif 4";
  src: url("/fonts/source-serif-4-latin-400-italic.woff2") format("woff2");
  font-weight: 400;
  font-style: italic;
  font-display: swap;
}

@font-face {
  font-family: "Source Serif 4";
  src: url("/fonts/source-serif-4-latin-700.woff2") format("woff2");
  font-weight: 700;
  font-style: normal;
  font-display: swap;
}

Variable Fonts

For a variable font, give font-weight a range so the browser knows one file covers them all:

@font-face {
  font-family: "Inter";
  src: url("/fonts/inter-latin-var.woff2") format("woff2");
  font-weight: 100 900;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: "Inter";
  src: url("/fonts/inter-latin-var-italic.woff2") format("woff2");
  font-weight: 100 900;
  font-style: italic;
  font-display: swap;
}

A Few Rules Worth Following

  • Use the same font-family name for every style of a family. Don't create separate families called "Inter Bold" and "Inter Italic"; you'll lose automatic bold and italic selection.
  • Always set font-display. Without it, most browsers hide text for up to about three seconds while the font loads. swap is the usual choice for brand fonts. The font-display guide explains every value.
  • Root-relative URLs (/fonts/...) are simplest. Relative URLs in @font-face resolve against the stylesheet's location, not the page's, which trips people up when CSS lives in a subfolder.
  • Only WOFF2 is needed in src for current browsers. Long src lists with EOT, SVG, and TTF are leftovers from a decade ago.

Step 6: Use the Fonts

Reference the family with a sensible fallback stack:

:root {
  --font-text: "Source Serif 4", Georgia, "Times New Roman", serif;
  --font-ui: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}

body {
  font-family: var(--font-text);
}

nav,
button,
input,
label {
  font-family: var(--font-ui);
}

The fallbacks matter. They're what readers see while the font loads, and permanently if it fails. Pick fallbacks with similar proportions, and consider tuning them with metric overrides so the swap doesn't shift the layout. Reducing Layout Shift with size-adjust and Font Metric Overrides shows how.

Step 7: Preload the Critical File

Fonts are discovered late, after the browser has the HTML and CSS. A preload hint starts the download earlier. Preload only the one or two files used for above-the-fold text:

<head>
  <link
    rel="preload"
    href="/fonts/inter-latin-var.woff2"
    as="font"
    type="font/woff2"
    crossorigin
  />
  <link rel="stylesheet" href="/css/main.css" />
</head>

The crossorigin attribute is required even though the font is on your own domain, because fonts are always fetched in CORS mode. Without it, the preloaded file isn't reused and the font downloads twice. Preloading too much backfires; Preloading Fonts: When It Helps and When It Hurts explains where the line is.

Step 8: Set Cache and Content-Type Headers

Font files rarely change, so cache them for a long time. If you version filenames when a font changes, you can mark them immutable:

Cache-Control: public, max-age=31536000, immutable
Content-Type: font/woff2

How you set these depends on your host.

Nginx

location ~* \.woff2$ {
  add_header Cache-Control "public, max-age=31536000, immutable";
  types { font/woff2 woff2; }
}

Netlify

# netlify.toml
[[headers]]
  for = "/fonts/*"
  [headers.values]
    Cache-Control = "public, max-age=31536000, immutable"

Vercel

{
  "headers": [
    {
      "source": "/fonts/(.*)",
      "headers": [
        {
          "key": "Cache-Control",
          "value": "public, max-age=31536000, immutable"
        }
      ]
    }
  ]
}

WOFF2 is already compressed internally, so there's no benefit to gzip or Brotli on top of it. Most servers skip it automatically for font types.

Serving Fonts from a Different Domain

If fonts live on a separate CDN domain from your pages, the server must send an Access-Control-Allow-Origin header, or browsers will refuse to use the fonts:

Access-Control-Allow-Origin: https://www.example.com

Serving fonts from the same origin as your pages avoids this entirely, and also avoids an extra connection.

Step 9: Verify It Works

Open your site with DevTools and check:

  1. Network panel, filtered to Font. Each file should load once, with a 200 status on the first visit and from cache on reload. Check the Content-Type and Cache-Control headers.
  2. No duplicate downloads. If a preloaded font appears twice, the crossorigin attribute or the URL doesn't match.
  3. No unused files. If a file never appears, either it isn't used (remove it) or a font-weight or font-style mismatch means the browser is picking a different file.
  4. No faux styles. If bold or italic text looks smeared or slanted rather than properly drawn, the browser is synthesizing it. Check that a matching @font-face exists.
  5. The console. Failed font loads, such as a 404 or a CORS error, show up here.

The Chrome DevTools Elements panel also shows, under Computed, which font was actually used to render the selected text ("Rendered Fonts").

Tip

To test the fallback experience, throttle the network to a slow profile and reload. You'll see what users on slow connections see during the swap, and whether the layout jumps.

Using a Framework?

Most frameworks have a font helper that does much of this for you. In Next.js, next/font downloads Google Fonts at build time, self-hosts them, generates @font-face rules, and creates a metric-adjusted fallback. If you're using Next.js, read Font Optimization in Next.js with next/font instead of doing it manually. In other stacks, Fontsource packages give you local files and CSS you can import.

Common Mistakes

  • Self-hosting without checking the license. Some commercial licenses don't allow it at all.
  • Separate family names per weight. It breaks the browser's style matching and leads to faux bold.
  • Missing crossorigin on preload. The font downloads twice.
  • Relative URLs that break. url("fonts/inter.woff2") resolves relative to the CSS file, not the page.
  • Short cache lifetimes. Fonts are ideal for long-term caching; a missing Cache-Control header means repeat downloads.
  • Shipping every weight from the download. Only include the files you actually reference.
  • Forgetting the license file. Open-source licenses usually require it to travel with the fonts.

Conclusion

Self-hosting web fonts comes down to a handful of steps: confirm the license, get WOFF2 files (variable if it makes sense), subset them, organize them with clear names, write one @font-face rule per file with font-display set, preload the critical one, and serve everything with long cache headers.

The result is fonts that load from the same connection as your page, are cached exactly as long as you want, and don't send visitor data to a third party. Once it's set up, it needs almost no maintenance, and you'll have full control the next time you need to tune performance.

Share :

Related Posts

A Short History of Typography: From Gutenberg to Variable Fonts

A Short History of Typography: From Gutenberg to Variable Fonts

When you write font-weight: 600 or font-style: italic, you're using vocabulary that is hundreds of years old. "Italic" comes from Renaissance Ita

Continue Reading
Accessible Typography: Font Size, Contrast, and Spacing Guidelines

Accessible Typography: Font Size, Contrast, and Spacing Guidelines

Most accessibility work on a website involves text. Navigation labels, form instructions, error messages, article bodies, button captions: if people

Continue Reading
Bauhaus Typography and Its Influence on Modern Design

Bauhaus Typography and Its Influence on Modern Design

Bold sans-serif type, thick black bars, red accents, text running at an angle, and lowercase-only headlines. If a poster or website uses that combina

Continue Reading