
How to self-host web fonts?
- Sajjad
- Typography
- 11 Oct, 2026
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:
- 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.
- Correct format: The Type column should show
woff2. - Headers are right: Check the response headers for
content-typeandcache-control. - The right font renders: Inspect a paragraph and check Rendered Fonts in the Computed tab.
- No duplicate downloads: If a preloaded font appears twice, the
crossoriginattribute 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 inurl()resolve from the stylesheet's location, not the page's. - Faux bold or slanted text: The
font-weightorfont-styledescriptors 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-Originheader. - 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.


