no-code.supply

BlogTutorial

How to self-host Google Fonts (and why your site should)

Why you should serve Google Fonts from your own domain, and how to do it step by step: download the files, write the @font-face rules, preload the right ones and check that nothing still calls Google.

By 12 min read

TL;DR

  • Google Fonts is free, but the usual way of using it makes every visitor's browser contact Google before your page can look right. You can skip that by hosting the font files yourself.
  • It takes four steps: download the woff2 files, write an @font-face rule for each, preload the one or two files your page needs first, and check that DevTools shows zero requests to Google.
  • The result: fewer connections, one less privacy question, and a site that doesn't break when someone else's server has a bad day.
  • It's the same on every project I build, and this site does exactly this.

Here's the whole thing in one block. The rest of the post explains each line.

@font-face {
  font-family: "Geist";
  font-style: normal;
  font-weight: 100 900;
  font-display: swap;
  src: url("/fonts/geist-normal-400-latin.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6,
    U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
<link
  rel="preload"
  href="/fonts/geist-normal-400-latin.woff2"
  as="font"
  type="font/woff2"
  crossorigin
/>

👉 Skip to the step-by-step


Why bother

The normal Google Fonts setup is one line of HTML. You paste a <link> tag, and the font works. It's popular because it's so easy.

What that line does behind the scenes: your visitor's browser asks fonts.googleapis.com for a stylesheet, reads it, then asks fonts.gstatic.com for the actual font files. Two extra servers, both owned by someone else. Here's why I stopped doing that.

Privacy

Every request to Google's servers tells Google that this visitor, with this IP address, just opened your page. You didn't ask for that, and your visitor didn't agree to it.

In Europe this has already been a legal question. On 20 January 2022 a Munich court (LG München I) decided that a website owner who loaded Google Fonts from Google's servers had passed a visitor's IP address to Google without permission, and ordered a €100 payment. It was one ruling in one country, and I'm not a lawyer, so don't take this as legal advice. But the fix is so cheap that I'd rather not find out how the next case goes.

If you host the fonts yourself, there's nothing to ask about. The font comes from your own domain, like the rest of your page.

Speed

Loading from Google means your browser has to open new connections: look up the address of another server, set up a secure connection to it, and only then start the download. That's work your own server has already done by the time the page arrives.

There was an old argument for the shared setup: if every site uses Google's copy of Roboto, a visitor probably has it cached from another site already. That stopped being true. Modern browsers keep a separate cache for each website, so a font cached while visiting site A isn't reused on site B. Chrome, Firefox and Safari all do this now (Chrome since 2020). So the shared cache saves you nothing, and you still pay for the extra connection.

Self-hosting also lets you decide which files load first. More on that in a moment, because it's the part that made the biggest difference for me.

Reliability

If Google's servers are slow or blocked on someone's network, your text sits waiting, or falls back to a font you didn't pick.

Files on your own domain share your site's fate. If the page loads, the font loads.

Control

The font files stay the same until you change them. You pick the weights, the languages and the format. Nobody can change the stylesheet under you.

On this site it's also a written rule. The project's AGENTS.md says there are no third-party requests: no Google Fonts, no outside analytics, no embeds loaded on page view. (I wrote about those files in How to write an AGENTS.md your AI agent actually follows.) Fonts are the easiest of those to get right, so they were the first thing I fixed.


How to do it by hand

There are four steps. You can do it with no tools beyond a browser and a text editor.

1. Find out which files Google serves

Open the Google Fonts page for your family and copy the embed URL. For Geist it looks like this:

https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700;800&display=swap

Open that URL directly in a browser tab. You'll see plain CSS: a list of @font-face rules, each with a src: url(https://fonts.gstatic.com/…woff2) line.

Two things to notice:

  • The files are woff2. That's the compressed font format every current browser supports. It's the only one you need. (Older formats like ttf or woff are for browsers from a decade ago.)
  • There are several rules per weight, each with a comment like /* latin */ or /* latin-ext */ and a unicode-range line. That's the subsetting I'll come back to.

Heads up: Google looks at who's asking and sends different CSS to different browsers. An old or unknown browser gets ttf files and no subsets. If you fetch the URL from a script, send a modern browser's User-Agent header, or you'll get the wrong thing.

2. Download the files

Save each woff2 file into your project, for example public/fonts/ in a Next.js project, or /fonts/ in a plain site. Give them names you can read, like geist-normal-400-latin.woff2.

Or use a tool. google-webfonts-helper lets you pick a family, weights and subsets and gives you the files plus the CSS. Fontsource packages the same fonts as npm packages, if you'd rather have them in node_modules:

npm install @fontsource-variable/geist
import "@fontsource-variable/geist";

The font's name in your CSS is then "Geist Variable", not "Geist".

3. Write the @font-face rules

Here's one rule, with what each line does:

@font-face {
  font-family: "Geist";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("/fonts/geist-normal-400-latin.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6,
    U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
  • font-family: the name you'll use in the rest of your CSS (font-family: "Geist", sans-serif).
  • font-weight: which weight this file is for.
  • font-display: swap: show the text right away in a fallback font, then swap to the real one when it arrives. The other option is a blank space until the font loads, which is worse.
  • src: where the file is. It now points to your own domain, which is the whole point.
  • unicode-range: which characters this file covers. The browser only downloads the file if the page uses at least one of those characters.

That last one is the clever part of what Google does. The latin file covers English and most of Western Europe, including Finnish ä and ö (they sit at U+00C4/U+00E4 and U+00D6/U+00F6, inside the latin range). The latin-ext file covers things like Polish, Czech and Turkish letters. You write one rule for each, and a page in English only ever downloads the first.

Keep every subset you actually need. I keep latin and latin-ext and drop the rest (Cyrillic, for one) because this site doesn't use them.

4. Preload the files the page needs first

Here's the catch. A browser finds a font file only after it has downloaded your CSS and worked out which rules apply. By then it has already drawn the page in the fallback font. When the real font arrives, the text changes size slightly and everything below it jumps. That jump is what Google's tools call layout shift (CLS).

A preload link fixes it: it tells the browser about the file in the HTML, so the download starts right away, in parallel with the CSS.

<link
  rel="preload"
  href="/fonts/geist-normal-400-latin.woff2"
  as="font"
  type="font/woff2"
  crossorigin
/>

Two details that trip people up:

  • crossorigin is required, even for files on your own domain. Fonts are always fetched in a mode that needs it. Without the attribute the browser downloads the file twice, once for the preload and once for the real use, and the preload is wasted.
  • Only preload what's above the fold. Every preloaded file competes with your other resources. I preload two files and nothing else.

Here's the real example from this site. The homepage uses Geist for text and Geist Mono for small labels, so app/layout.tsx preloads the Latin file of each. The calls sit at the top of the root layout component, before it returns the page:

import { preload } from "react-dom";

export default function RootLayout({ children }: { children: ReactNode }) {
  preload("/fonts/geist-normal-400-latin.woff2", { as: "font", type: "font/woff2", crossOrigin: "anonymous" });
  preload("/fonts/geist-mono-normal-400-latin.woff2", { as: "font", type: "font/woff2", crossOrigin: "anonymous" });

  return (
    // <html>, <body> and the rest of the layout
  );
}

preload from react-dom is how a React 19 app adds a <link rel="preload"> to the page's head, and it works in a server component like this layout. The two files are the Latin ones from app/fonts.css. crossOrigin: "anonymous" is the same thing as the crossorigin attribute above.

Before I added these two lines, Lighthouse measured a layout shift of 0.104 on the homepage, caused by the font swap. After: 0.


The variable font shortcut

Geist is a variable font. One file holds every weight from thin to black, instead of one file per weight.

You can see it in my files. public/fonts/ has just four woff2 files in total: Geist and Geist Mono, each in latin and latin-ext. Together they're about 84 KB (29.3 and 16.5 KB for Geist, 23.1 and 14.7 KB for Geist Mono). The stylesheet still has a rule per weight (400, 500, 600, 700, 800 for Geist), but every rule points to the same file. The 400 in the file names is a leftover from how my script named them; each file holds the whole weight range.

If your font is variable, you can write one rule with a weight range instead. Google serves Geist with a range of 100 to 900:

@font-face {
  font-family: "Geist";
  font-weight: 100 900;
  src: url("/fonts/geist-normal-400-latin.woff2") format("woff2");
}

The saving is real when you use several weights. Five static weights are five downloads. One variable file is one. Check the Google Fonts page for your family: if it shows a weight slider, it's variable.


The fast way: a script

The first time I did this by hand it was slow and fiddly. After that I wrote a script, and you can have it: fetch-google-fonts.mjs. It needs Node 18 or newer. You give it the Google Fonts URL and an output folder:

node fetch-google-fonts.mjs \
  "https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700;800&display=swap" \
  public/fonts --url-prefix=/fonts/ --css=app/fonts.css

Swap in the URL for your own family. --url-prefix is the path the browser will use for the files, and --css is where the stylesheet goes.

It does what the four steps above do:

  1. Fetches the CSS URL, pretending to be a current Chrome so Google answers with woff2 and the subsets.
  2. Reads each @font-face block and keeps the subsets I ask for (latin and latin-ext by default).
  3. Downloads each font file once, even when several weights share it, and writes it into the output folder.
  4. Writes a new stylesheet with the URLs pointing at my own folder instead of Google's. Weights that share one file are merged into a single rule with a range.
  5. Fetches each family's licence text and saves it in public/fonts/licenses/.
  6. Writes a fonts.json that lists each family, its files and its licence.

It's 99 lines of Node with no dependencies, so read it before you run it. If you'd rather not run anything, the manual steps above give the same result.


In Next.js: next/font/google

If you use Next.js there's a built-in route that does most of this for you: next/font/google.

import { Geist } from "next/font/google";

const geist = Geist({ subsets: ["latin"] });

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" className={geist.className}>
      <body>{children}</body>
    </html>
  );
}

At build time Next.js downloads the font from Google and serves it from your own domain with the rest of your static assets, so your visitors' browsers never contact Google. It also preloads the subsets you list and adds a size-adjusted fallback font to reduce layout shift, both on by default. For a variable font like Geist you don't need to set a weight; for a static one you do.

It's a good choice, and if your site is Next.js and nothing else, use it. I still did my own for this site, for one reason: the templates come in plain HTML editions with no build step, so I wanted one method that works the same in every edition. Doing it by hand also keeps the files and the licence text visible in the repository, and gives me full control over what gets preloaded.


Don't forget the licence

Most Google Fonts use the SIL Open Font License (OFL). Geist does. Some use the Apache 2.0 licence instead, and a few (the Ubuntu fonts) use the Ubuntu Font Licence. Each family's folder in the google/fonts repository has its own licence file, which is the one to trust.

In plain words, the OFL lets you use, copy, embed and ship the font for free, including in commercial projects and bundled with your own software, and that covers self-hosting. What it asks in return:

  • You can't sell the font files by themselves.
  • Keep the copyright notice and the licence text with the font.
  • If you modify the font, the modified version stays under the OFL, and it can't use the font's Reserved Font Name if the licence names one.

So put the licence file next to the fonts. My public/fonts/licenses/ folder has geist-OFL.txt and geist-mono-OFL.txt, and a small fonts.json lists each family, its files and its licence. It takes almost no effort, and it saves a bad afternoon if someone ever asks.


How to check it worked

Don't trust that it worked. Look.

  1. Open your site in Chrome and open DevTools (right-click, Inspect).
  2. Go to the Network tab and tick Disable cache.
  3. Type fonts in the filter box, or click the Font filter, and reload.
  4. You should see only requests to your own domain, ending in .woff2.
  5. Now filter for googleapis and then gstatic. Both should show zero requests.

Some things to check at the same time:

  • No request goes to fonts.googleapis.com or fonts.gstatic.com.
  • Each font file loads once. Twice means a missing crossorigin on the preload.
  • The preloaded files are the first font requests in the list.
  • Run Lighthouse and look at the layout shift number. It should be 0 or very close.
  • Search your code for fonts.googleapis.com. Old <link> tags and @import lines in CSS files hide in surprising places, and one forgotten line keeps the whole thing going.

FAQ

Is self-hosting Google Fonts allowed?

Yes. The fonts are open source, and the licences allow you to download them and serve them yourself. Google even offers a download button on every family's page. Keep the licence file with the fonts.

Is it faster?

Usually, yes, because there are no extra connections to set up, and you can preload the files you care about. Measure it on your own site: how much faster depends on where your server is and what else your page loads. I measured layout shift going from 0.104 to 0 here, but that came from the preload, not from self-hosting alone.

Do I need to update the files when Google updates a font?

No, nothing updates by itself. Your own copy stays exactly as you downloaded it. So ask yourself how often you really need a font updated, and whether you want it to change under a live design without you deciding. If you do want a new version, run the download again.

Should I use woff2 only?

Yes. Every current browser supports it, and it's the smallest format. You only need ttf or woff if you have to support very old browsers, which most sites don't.


Wrap-up

Self-hosting Google Fonts is a small job. You get a page that asks no one else for permission, loads in fewer steps, and has a font that stays put.

If you read this far, thank you. 🙏

Every no-code.supply template ships with its fonts self-hosted, files and licences included, so a template works the same with or without a connection to anyone else's server.

More from the blog