How to automate google fonts self-hosting in your build pipeline
- Step 1Define a fonts config in source control — Keep families and weights in one versioned file, e.g. `fonts.config.json`: `{ "families": ["Inter:wght@400;700", "Playfair Display:wght@700;900"] }`. The strings are exactly what you'd type into the generator's Families field — CSS2 specifiers, comma-separated when you pass them as one list.
- Step 2Build the css2 URL the same way the tool does — For each family, `family=` + `encodeURIComponent(name).replace(/%20/g, '+')`; join with `&`; append `&display=swap`. The percent-encoded `:`, `@`, `;`, `,` in axis specifiers are accepted by Google's API (verified: it returns 200, not 400).
- Step 3Fetch with a Chrome User-Agent — This is the step the browser does for free and Node does not: `fetch(url, { headers: { 'User-Agent': '<recent Chrome UA>' } })`. Without it, Google returns TTF `src` URLs and **no** `unicode-range` subset blocks — much larger, unsplit files. With it you get the same WOFF2 + per-script subsets a browser sees.
- Step 4Extract, download, and rewrite — Regex every `url(https://fonts.gstatic.com/...)` out of the CSS, dedupe, `fetch` each into `public/fonts/woff2/` (filenames are Google's opaque hashes), then string-replace each upstream URL in the CSS with `url("/fonts/woff2/<file>")`. Write the rewritten CSS next to the WOFF2.
- Step 5Hash and pin — Compute `crypto.createHash('sha256')` over each downloaded WOFF2 and write the digests to `fonts.lock.json`. On the next CI run, re-fetch and compare: identical hashes → Google hasn't changed the font; different → an upstream revision landed and you should review before shipping.
- Step 6Cache between CI runs and commit the output — Key your CI cache on the SHA-256 of `fonts.config.json` so a run with an unchanged config restores `public/fonts/` instead of re-fetching. Commit the WOFF2, the rewritten CSS, and `fonts.lock.json`. Most teams regenerate only on a config change — quarterly or at a design refresh.
The four steps the in-browser tool runs, and the CI equivalent
The generator and a CI script do the same work. The only thing the browser gets for free that a Node script must add explicitly is a real browser User-Agent.
| Step | In the browser tool | In a Node CI script |
|---|---|---|
| Build the css2 URL | From the Families field + font-display select | From fonts.config.json, same encodeURIComponent(...).replace(/%20/g,'+') encoding |
| Fetch the CSS | fetch(url, { mode: 'cors' }) — browser sends a real Chrome UA automatically | Must set headers: { 'User-Agent': '<Chrome UA>' } or you get TTF |
| Get WOFF2 + subsets | Yes — browser UA earns WOFF2 and unicode-range blocks | Only with the Chrome UA; default Node UA → TTF, no subsets |
| Rewrite + emit downloader | Relative ./woff2/ paths + a bash curl script in a comment | Your script writes files directly and rewrites to your chosen path |
User-Agent determines the response — measured
Why step 3 is non-negotiable in Node. The same css2 URL returns different src formats depending on the User-Agent the client sends.
| Client / User-Agent | src format returned | unicode-range subsets? |
|---|---|---|
| Browser (this tool) or explicit Chrome UA | WOFF2 | Yes — one @font-face per script subset |
Node fetch with default UA (or none) | TTF (truetype) — ~3× larger | No — single block, no per-script splitting |
| Old Safari / legacy UA | WOFF | Varies |
| IE-era UA | EOT / TTF | No |
Pinning options — Google has no public version API
There is no documented version parameter for css2. These are the practical ways to make builds reproducible and detect upstream changes.
| Approach | How | Reproducible? |
|---|---|---|
| Commit the WOFF2 | Download once, commit the binaries to the repo | Yes — fully pinned; deploys never re-fetch |
| SHA-256 lockfile | Hash each WOFF2 into fonts.lock.json, compare on re-fetch | Detects change; commit the binaries to fully pin |
| Cache by config hash | CI cache keyed on SHA-256 of fonts.config.json | Skips re-fetch when config is unchanged |
| Re-fetch every build (don't) | Call the API on every deploy | No — silent rendering drift if Google revises the font |
Cookbook
A self-contained Node script and the JAD runner API equivalent. For the privacy rationale and the manual one-off flow these automate, see the GDPR self-hosting guide; to wire the output into a zero-downtime production rollout see the marketing-site migration. For physically trimming the downloaded WOFF2 to only the glyphs you ship, chain the font subsetter.
Minimal Node fetch script — the four steps
ExamplePure Node, no dependencies. The load-bearing line is the User-Agent header; without it you get TTF and no subset blocks.
import { mkdir, writeFile } from 'node:fs/promises';
const UA =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' +
'(KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36';
const families = ['Inter:wght@400;700', 'Playfair Display:wght@700;900'];
const params = families
.map((f) => 'family=' + encodeURIComponent(f).replace(/%20/g, '+'))
.join('&');
const url = `https://fonts.googleapis.com/css2?${params}&display=swap`;
let css = await (await fetch(url, { headers: { 'User-Agent': UA } })).text();
const urls = [...css.matchAll(/url\((https:\/\/fonts\.gstatic\.com\/[^)\s]+)\)/g)]
.map((m) => m[1]);
await mkdir('public/fonts/woff2', { recursive: true });
for (const u of new Set(urls)) {
const file = u.split('/').pop();
const buf = Buffer.from(await (await fetch(u)).arrayBuffer());
await writeFile(`public/fonts/woff2/${file}`, buf);
css = css.replaceAll(u, `/fonts/woff2/${file}`);
}
await writeFile('public/fonts/google-fonts.css', css);Add a SHA-256 lockfile to detect upstream changes
ExampleGoogle won't version the URL for you, so hash each file. A changed digest on a later run means Google revised the font — review before shipping.
import { createHash } from 'node:crypto';
import { readdir, readFile, writeFile } from 'node:fs/promises';
const dir = 'public/fonts/woff2';
const lock = {};
for (const f of await readdir(dir)) {
lock[f] = createHash('sha256')
.update(await readFile(`${dir}/${f}`))
.digest('hex');
}
await writeFile('public/fonts/fonts.lock.json', JSON.stringify(lock, null, 2));
// In CI: re-run the fetch, recompute, diff against the committed lock.JAD runner API — same generator, no UA juggling
ExamplePair the @jadapps/runner once, then POST the same config you'd type into the tool. The runner runs the generator and returns the rewritten CSS, so you don't hand-roll the URL building or UA.
# Discover the option schema
curl -s http://127.0.0.1:9789/v1/tools/google-fonts-css-generator/schema
# Generate (this tool takes no file — options only)
curl -sS -X POST \
http://127.0.0.1:9789/v1/tools/google-fonts-css-generator/run \
-H 'Content-Type: application/json' \
-d '{"googleFontFamilies":"Inter:wght@400;700","fontDisplayValue":"swap"}' \
-o public/fonts/google-fonts.cssCache the fonts directory by config hash in GitHub Actions
ExampleSkip the network entirely when the config hasn't changed. Restores public/fonts/ from cache in well under a second.
- name: Cache self-hosted fonts
uses: actions/cache@v4
with:
path: public/fonts
key: fonts-${{ hashFiles('fonts.config.json') }}
- name: Generate fonts (cache miss only)
run: node scripts/fetch-fonts.mjsTrim the downloaded WOFF2 to the glyphs you actually ship
ExampleThe fetch pulls every script subset Google offers. For an English-only app, subset each WOFF2 down with the JAD runner's subsetter after the fetch step.
for f in public/fonts/woff2/*.woff2; do
curl -sS -X POST http://127.0.0.1:9789/v1/tools/font-subsetter/run \
-F "file=@$f" \
-F 'inputs={"charset":"latin","format":"woff2"}' \
-o "${f%.woff2}.latin.woff2"
done
# See /font-tools/font-subsetter for the in-browser equivalent.Edge cases and what actually happens
Every row below was probed against the live API. Some documented requirements (alphabetical axis order, numerical tuple order) are not actually enforced in practice — useful to know if you've been blaming the wrong thing for a 400.
Forgetting the Chrome User-Agent in Node
Wrong format — TTFDefault fetch in Node sends a non-browser UA, and Google responds with .ttf src URLs and a single @font-face per family — no unicode-range subset blocks. You end up self-hosting ~3× the bytes with no per-script tree-shaking. Always set a recent Chrome User-Agent header (verified: with it you get WOFF2 + 7 subsets for Inter; without it you get TTF and no subsets). The in-browser tool sidesteps this because the browser already sends a real UA.
Invalid axis or typo'd family in the config
400 Bad RequestAn unknown family (NotARealFont123) or an axis the family lacks (Inter:wdth@100) makes Google return HTTP 400 with an HTML body. A naïve script that assumes 200 will write the HTML error page into your CSS file. Check res.ok and fail the build loudly; the in-browser tool surfaces this as Google Fonts API returned 400 Bad Request.
Opaque gstatic filenames collide or confuse
By designDownloaded files are content hashes like UcC73Fwr....woff2, not Inter-Regular.woff2. They're unique per file so collisions are unlikely, but they're unreadable in a repo. If you want meaningful names, map each gstatic URL to a derived name (family + subset + weight) during the rewrite step and rename consistently in both the file write and the CSS src.
Re-fetch produces different hashes months later
Expected on revisionGoogle occasionally publishes a new revision of a family; the gstatic hash filenames and the file bytes then change. The SHA-256 lockfile catches this. Treat a hash change as a signal to re-review rendering (run visual regression), not as a build failure to silently ignore — and commit the new binaries to re-pin.
Re-fetching on every deploy
Non-reproducibleCalling the API on every build makes your deploys depend on Google's CDN being up and unchanged, and risks silent rendering drift if a revision lands mid-release. Commit the WOFF2 and only regenerate on a config change. Cache the fonts directory keyed on the config hash so unchanged configs never hit the network.
URL byte limit on a huge text= or family list
400 above the limitIf your config builds an enormous request (a very long text= value or dozens of families), the full URL can exceed Google's ~16 KB limit and return 400. Split into multiple requests and concatenate the resulting CSS, deduping the gstatic URLs across them before downloading. Most real configs are nowhere near this.
Rewriting with String.replace instead of replaceAll
Partial rewriteEach gstatic URL can appear more than once in the CSS (the same variable WOFF2 referenced by multiple weight blocks). A single css.replace(url, ...) rewrites only the first occurrence, leaving live gstatic URLs in the output — your 'self-hosted' CSS still leaks. Use replaceAll (or a global regex), exactly as the in-browser tool does.
CDN strips or normalizes the WOFF2 Content-Type
Possible MIME issueWhen you serve the committed WOFF2 from your own CDN, make sure it sends Content-Type: font/woff2. Some static hosts default to application/octet-stream, which most browsers tolerate for a format('woff2') hint but is worth setting correctly. This is a serving-config concern, not a generation one.
Running the script behind a corporate proxy
Fetch may failCI runners behind a proxy that intercepts TLS may break the fetch to fonts.googleapis.com/fonts.gstatic.com. Configure HTTPS_PROXY for the Node process, or commit the WOFF2 from a developer machine and have CI consume the committed files (the recommended pinned approach anyway).
Two families sharing a subset file
By design — dedupeDifferent families never share a WOFF2, but within one family multiple weight/style blocks can point at the same variable file. Always dedupe the extracted URL list (new Set(urls)) before downloading so you fetch each file once — the in-browser tool's 'WOFF2 files referenced' metric reflects this deduped count.
Frequently asked questions
Why must I send a Chrome User-Agent from Node?
Google's css2 endpoint returns different src formats based on the User-Agent. A modern browser UA gets WOFF2 with per-script unicode-range subset blocks; a default Node fetch UA gets TTF with no subsets — roughly 3× the bytes and no tree-shaking. We verified this directly: with a Chrome UA, Inter:wght@400;700 returns WOFF2 across 7 subsets; with the default Node UA it returns .ttf and no unicode-range. The in-browser tool doesn't need this step because the browser already sends a real UA.
How do I pin a Google Fonts version?
There's no public version parameter. The reliable way is to download the WOFF2 once and commit the binaries — your deploys then never re-fetch. Add a fonts.lock.json of SHA-256 digests so a later re-fetch can detect whether Google revised the font (the gstatic hash filename and bytes change on a revision). Re-fetching on every build is the anti-pattern: it makes deploys depend on an unversioned upstream.
Can I cache the fonts between CI runs?
Yes. Key your CI cache on the SHA-256 of fonts.config.json. When the config hasn't changed, the cache restores public/fonts/ and the fetch step is skipped entirely — a cache hit restores in well under a second versus seconds of network fetches. Re-runs only hit Google when you actually change families or weights.
Does the script handle variable fonts?
Yes — put a weight range in the config (Inter:wght@100..900) and Google returns the variable WOFF2 with a font-weight: 100 900 range. The extract-download-rewrite steps are identical; you just end up with fewer, larger files. The in-browser generator handles the same input the same way.
Should I use the script or the JAD runner API?
Use the runner API if you want the exact same logic as the in-browser tool without hand-rolling URL building, UA headers, and rewrite regexes: POST http://127.0.0.1:9789/v1/tools/google-fonts-css-generator/run with { googleFontFamilies, fontDisplayValue } and you get the rewritten CSS back. Use the hand-written Node script if you want full control over output paths, filenames, and lockfile format. Both are pure-Node-friendly with no native deps.
What does the runner POST body look like — does it take a file?
No file. This is a generative tool, so the runner endpoint takes options only: { "googleFontFamilies": "Inter:wght@400;700", "fontDisplayValue": "swap" } as JSON. (The runner path also accepts the googleFontUrl field, which the browser UI doesn't expose — handy if you already have a full css2 URL.) GET .../schema returns the option list so you can validate before posting.
How do I detect when Google has updated a font?
Compare SHA-256 hashes. Keep a committed fonts.lock.json of each WOFF2's digest. In CI, re-fetch (cache-busted) and recompute; if a digest differs, Google published a revision. Fail or flag the build, run visual-regression snapshots to confirm the rendering is still acceptable, then commit the new binaries and lockfile to re-pin. Without hashing you'd never know an upstream change shipped.
Why are the downloaded filenames gibberish?
They're Google's content-addressed names on fonts.gstatic.com (e.g. UcC73Fwr....woff2). The in-browser tool keeps them verbatim so the curl -o targets and the rewritten src paths always match. In your own script you can remap them to readable names (family-subset-weight) during the rewrite — just make sure the file write and the CSS src use the same name.
Can I subset the downloaded fonts in the same pipeline?
Yes — chain a subset step after the fetch. The generator pulls every script subset Google offers; for an English-only app, run each WOFF2 through the JAD runner's subsetter (POST .../font-subsetter/run with charset: latin) to drop non-Latin glyphs. See the font subsetter for the in-browser equivalent and the charset options.
Will a 400 from a bad config corrupt my output?
Only if your script ignores the status. A 400 returns an HTML error body; a script that blindly writes res.text() to the CSS file will embed the error page. Always check res.ok and abort the build on failure. The in-browser tool throws Google Fonts API returned 400 Bad Request. rather than producing a broken file.
What's a sensible regeneration cadence?
Regenerate when the config changes — i.e. when you add/remove a family or weight — not on a schedule. Most teams touch it quarterly or at a design refresh. Because the WOFF2 are committed and pinned, there's no per-deploy cost and no urgency to chase Google revisions; pull them deliberately when you're ready to re-test rendering.
Is this safe to run on every PR?
With config-hash caching, yes — PRs that don't touch fonts.config.json restore the cached fonts and never hit Google. PRs that do change it regenerate and the diff shows the new WOFF2 and rewritten CSS for review. That's the ideal: font changes are visible in the PR, pinned in the commit, and reproducible on deploy.
Privacy first
Every JAD Font tool runs entirely in your browser using opentype.js and the wawoff2 WASM Brotli encoder. Your fonts never leave your device — verified by zero outbound network requests during processing.