Type something to search...
How to self-host web fonts?

How to self-host web fonts?

To self-host web fonts, you download the font files in WOFF2 format, put them in a folder on your own server or in your build output, and declare each style with an @font-face rule that points to those files. You then apply the family with font-family, serve the files with a long cache lifetime and the font/woff2 content type, and optionally preload the one or two fonts that appear above the fold. The source files can come from a foundry, from Google Fonts or from an npm package such as Fontsource, provided the licence allows self-hosting. Once set up, the browser fetches fonts from your own domain with no third-party connection.

Self-hosting gives you full control over which files load, how they're cached and when they're requested, and it removes a third-party request from every page view. It's also simple once you've done it once. In this article you'll learn where to get the files, how to organise them, how to write correct @font-face rules for static and variable fonts, how to configure Nginx, Apache and common hosts, how to load fonts through npm in a build tool, and how to check everything is working.

Step 1: Get the Right Files

You need WOFF2 files for each weight and style you plan to use. There are several sources.

From a Foundry

If you've bought a web licence, the foundry will usually provide a web kit containing WOFF2 files. Read the licence for any conditions, such as page view limits, a requirement to include a licence comment in your CSS, or a restriction on which domains can use the files.

From Google Fonts

Every family on Google Fonts is released under an open licence, usually the SIL Open Font License, which allows self-hosting. The download button on a family's page gives you TTF files, including variable fonts where available. Convert them to WOFF2 and subset them yourself, or use a tool that does it for you.

From Fontsource

Fontsource publishes open-source fonts, including the Google Fonts catalogue, as npm packages with ready-made WOFF2 files and CSS. It's the easiest route if you use a JavaScript build tool. The variable version of a family is usually published under the @fontsource-variable scope:

npm install @fontsource-variable/inter

Converting Desktop Files

If you only have TTF or OTF files, convert them with fonttools:

pip install fonttools brotli
fonttools ttLib.woff2 compress -o inter-latin-var.woff2 InterVariable.ttf

Subsetting at the same time cuts file size further, which is covered in detail in the guide to font subsetting.

Step 2: Organise the Files

Keep fonts in a dedicated folder with predictable, lowercase names that include the family, style and subset. A clear structure makes @font-face rules easy to read and avoids typos:

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

If your build tool fingerprints assets, filenames will gain a hash such as inter-latin-var.3f9a1c.woff2. That's ideal, because it lets you cache the files permanently.

Step 3: Write the @font-face Rules

Each @font-face rule describes one file: the family name you'll use in CSS, where the file lives, and which weight and style it represents.

Static Fonts

With static fonts, you need one rule per weight and style combination. Use the same font-family name in each rule so the browser treats them as one family:

@font-face {
  font-family: "Source Serif 4";
  src: url("/fonts/source-serif-4-latin-400-normal.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-normal.woff2") format("woff2");
  font-weight: 700;
  font-style: normal;
  font-display: swap;
}

Grouping styles under one family name matters. If you give each file its own family name, such as "Source Serif Bold", then font-weight: 700 and em elements won't pick the right file and the browser will synthesise fake bold or italic instead.

Variable Fonts

A variable font covers a range of weights in a single file, so one rule is enough. Declare the full weight range the font supports:

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

The second source is a deliberate fallback for browsers that don't yet understand tech(), which would otherwise discard the whole first entry. Both point to the same file, so it's only downloaded once.

Restricting by Character Range

If you split a font into subsets, such as Latin and Latin Extended, use unicode-range so the browser only downloads a subset when the page contains characters from it:

@font-face {
  font-family: "Inter";
  src: url("/fonts/inter-latin-ext-var.woff2") format("woff2");
  font-weight: 100 900;
  font-display: swap;
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7,
    U+02DD-02FF, U+1E00-1E9F, U+20A0-20AB, U+20AD-20C0, U+2113,
    U+2C60-2C7F, U+A720-A7FF;
}

Step 4: Apply the Fonts

Reference the family names in your styles, always followed by fallbacks:

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

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

h1,
h2,
h3,
nav,
button {
  font-family: var(--font-ui);
}

Browsers only download a font file when an element on the page actually needs it. Declaring a rule for a bold italic you never use costs nothing beyond a few bytes of CSS.

Step 5: Configure the Server

Fonts are static, versioned assets. Serve them with the right content type, compression settings and caching.

Content Type and Caching

The headers you want on a font response are:

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

Only use immutable with a one-year lifetime if filenames change when the file changes. If you overwrite files in place, use a shorter max-age so visitors pick up updates.

WOFF2 is already Brotli-compressed, so there's no benefit to gzip or Brotli compressing it again on the server.

Nginx

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

Recent Nginx versions already map .woff2 to font/woff2 in their default mime.types, but setting it explicitly does no harm.

Apache

In .htaccess or the virtual host configuration:

AddType font/woff2 .woff2

<IfModule mod_headers.c>
  <FilesMatch "\.woff2$">
    Header set Cache-Control "public, max-age=31536000, immutable"
  </FilesMatch>
</IfModule>

Netlify and Vercel

On Netlify, add a _headers file to your publish directory:

/fonts/*
  Cache-Control: public, max-age=31536000, immutable

On Vercel, add a headers entry to vercel.json:

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

Serving From a Different Domain

Browsers fetch fonts using CORS. If your fonts live on a CDN or subdomain that differs from the page's origin, the font response must include an Access-Control-Allow-Origin header, or the browser will refuse to use it:

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

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

Step 6: Preload the Critical Font

Fonts are discovered late, after the browser has downloaded and parsed your CSS and found an element that uses them. For the font used in above-the-fold text, add a preload in the head:

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

The crossorigin attribute is required even for same-origin fonts, because font requests are always made in CORS mode. Without it, the browser downloads the file twice. Limit preloads to one or two files; the details are in the guide to preloading fonts correctly.

Using Fontsource in a Build Tool

If you installed a Fontsource package, import it once in your entry file and your bundler will copy the font files into the build output and fingerprint them:

// main.js or your root layout
import "@fontsource-variable/inter";

Then use the family name the package documents:

body {
  font-family: "Inter Variable", system-ui, sans-serif;
}

Fontsource packages also expose individual subsets and weights if you want finer control, for example importing only the Latin subset instead of every language the font supports. Check the package's documentation for the exact import paths.

Some frameworks have their own font tooling that self-hosts automatically. For example, Next.js's next/font downloads Google Fonts at build time and serves them from your own domain.

Step 7: Verify It Works

Check each of these after deploying:

  1. Files load from your domain: In DevTools, open Network, filter by Font and reload. Every font should come from your origin with a 200 status, or from cache.
  2. Correct format: The Type column should show woff2.
  3. Headers are right: Check the response headers for content-type and cache-control.
  4. The right font renders: Inspect a paragraph and check Rendered Fonts in the Computed tab.
  5. No duplicate downloads: If a preloaded font appears twice, the crossorigin attribute or the URL doesn't match.

From the command line:

curl -sI https://www.example.com/fonts/inter-latin-var.woff2 \
  | grep -iE "content-type|cache-control"
content-type: font/woff2
cache-control: public, max-age=31536000, immutable

Common Problems

  • Text shows in a fallback font forever: Usually a wrong path in src. Check the Network panel for a 404, and remember that relative URLs in url() resolve from the stylesheet's location, not the page's.
  • Faux bold or slanted text: The font-weight or font-style descriptors don't match the file, or each style was given a different family name.
  • Font blocked by CORS: The files are on another origin without an Access-Control-Allow-Origin header.
  • Updates not appearing: Files are cached as immutable but were overwritten in place. Use fingerprinted names.

FAQ: Self-Hosting Web Fonts

Yes. Google Fonts families are released under open licences, most often the SIL Open Font License, which allows you to download, host and serve them yourself. Keep the licence file with your project.

Not for current browsers. All modern browsers support WOFF2, so a single WOFF2 source per style is enough. Visitors on very old browsers will see your fallback font.

Anywhere served as static files, commonly a fonts folder in your public or static directory. Serving them from the same origin as your pages avoids CORS configuration and an extra connection.

Almost always because the preload link is missing the crossorigin attribute, or its URL differs from the one in your @font-face rule. Both must match exactly for the browser to reuse the preloaded file.

Not WOFF2. It's already compressed with Brotli, so extra compression adds CPU time without saving bytes. Uncompressed TTF or OTF files do benefit, but you shouldn't be serving those.

If filenames include a content hash or version, cache them for a year with the immutable directive. If you overwrite files in place, use a shorter lifetime so changes reach visitors.


Conclusion

Self-hosting web fonts comes down to a handful of steps: get licensed WOFF2 files, store them with clear names, describe each style with an @font-face rule under a shared family name, apply the family with sensible fallbacks, and serve the files with the correct content type and long-lived caching. Preloading the main above-the-fold font then closes most of the gap between your HTML arriving and your text rendering correctly.

Once it's in place there's little maintenance. Check the Network panel after each deploy, use fingerprinted filenames so caching never serves stale files, and only add new styles when the design genuinely needs them. You'll end up with fonts that load from your own domain, on your terms.

Share :

Related Posts

How to make typography accessible?

How to make typography accessible?

You make typography accessible by choosing clear typefaces, setting text in relative units so it scales with user preferences, giving it enough colou

Dive Deeper
What are the parts of a letterform?

What are the parts of a letterform?

A letterform is the shape of a single letter, and typographers break it down into named parts. The main ones are the stem (the main vertical

Dive Deeper
What are ascenders, descenders and the baseline?

What are ascenders, descenders and the baseline?

The baseline is the invisible line that letters sit on. Ascenders are the parts of lowercase letters that rise above the x-height, such as th

Dive Deeper