How to automate ttf-to-woff2 conversion in your build pipeline
- Step 1Pick an engine — Three real choices: the `wawoff2` npm package (pure JS/WASM, no native deps), Google's `woff2_compress` CLI or `fonttools` with `--flavor=woff2` (native/Python, the reference), or the JAD runner's HTTP API (no toolchain in CI). All three produce a standards-compliant WOFF2.
- Step 2Write a converter step — Walk `src/fonts/*.{ttf,otf}`, compress each to `public/fonts/<name>.woff2`, and make it idempotent — skip when the output is newer than the source so unchanged fonts aren't re-compressed every build.
- Step 3Hook it into the build — Add a `prebuild` script (`"prebuild": "node scripts/woff2.mjs"`) so `npm run build` (and `next build`) generate WOFF2 first. Decide whether to commit the generated files or `.gitignore` them and rely on CI to produce them.
- Step 4Subset before compressing (optional but high-impact) — Compression and subsetting are different levers. For the smallest output, subset first — `pyftsubset`/`hb-subset` in the same step, or the JAD runner's [Font Subsetter](/font-tools/font-subsetter) endpoint — then WOFF2-compress the subset.
- Step 5Add a CI guard — Run the converter in GitHub Actions, then `git diff --exit-code public/fonts/`. If someone added a TTF without committing the WOFF2, the diff is non-empty and the PR fails. Add a size-budget check on each output for good measure.
- Step 6Emit the @font-face — Generate the CSS once with the [Font Face Generator](/font-tools/font-face-generator) (WOFF2-only by default) and point your build at the generated `src`. Preload the critical weight with the [Preload Tag Builder](/font-tools/preload-tag-builder).
Engines for build-time WOFF2
All four apply the same WOFF2 spec (Brotli + glyf/loca transform). The difference is the runtime they need and how you drive them.
| Engine | Runtime | Drive it via | Best for |
|---|---|---|---|
wawoff2 (npm) | Node + WASM | compress(Uint8Array) in a script | Pure-JS CI, no native deps — same lib as the browser tool |
woff2_compress (Google) | Native C++ | CLI: woff2_compress font.ttf | Reference encoder; fastest on big batches |
fonttools (Python) | Python | fonttools ttLib ... --flavor=woff2 / pyftsubset | When you also subset/instance in the same step |
| JAD runner API | Local Node (WASM) | POST 127.0.0.1:9789/v1/tools/ttf-to-woff2/run | Locked-down CI with no Python/native toolchain |
JAD runner endpoint for this tool
The runner exposes the same processors over a local HTTP API. ttf-to-woff2 has an empty options schema — just POST the file.
| Concern | Value |
|---|---|
| Run endpoint | POST http://127.0.0.1:9789/v1/tools/ttf-to-woff2/run |
| Request body | multipart/form-data: files field with the font, optional options JSON |
| Options for this tool | None — schema is []; the engine takes no parameters |
| Schema endpoint | GET /api/v1/tools/ttf-to-woff2 returns the (empty) option list |
| Health check | GET http://127.0.0.1:9789/v1/health |
| Where it runs | On your machine via @jadapps/runner — font bytes never reach JAD's servers |
Where to put the conversion step
Trade-offs for the three hook points. Build-time is the right default — request-time re-compresses the same bytes for every visitor.
| Hook point | Pros | Cons |
|---|---|---|
prebuild Node script | Simple, idempotent, runs before any bundler | You own the file-walking logic |
| Bundler asset rule (Vite/Webpack) | Integrates with hashing + emit | Pulls fonts through the bundler graph; config-heavy |
| Request-time edge function | No build step | Wasteful — same font re-compressed on every request; latency |
Cookbook
Copy-paste starting points for each engine and the CI guard. Adapt paths and the font glob to your project. All four engines emit a standards-compliant WOFF2.
Node prebuild with the wawoff2 package (no native deps)
ExampleSame library the browser tool uses. Walk source fonts, compress, write WOFF2. Idempotent on mtime so unchanged fonts are skipped.
// scripts/woff2.mjs — npm i wawoff2
import { readdir, readFile, writeFile, stat } from 'node:fs/promises';
import { compress } from 'wawoff2';
const SRC = 'src/fonts', OUT = 'public/fonts';
for (const f of await readdir(SRC)) {
if (!/\.(ttf|otf)$/i.test(f)) continue;
const out = `${OUT}/${f.replace(/\.(ttf|otf)$/i, '.woff2')}`;
const src = `${SRC}/${f}`;
const fresh = await stat(out).then(s => s.mtimeMs, () => 0);
if ((await stat(src)).mtimeMs <= fresh) continue; // up to date
await writeFile(out, await compress(await readFile(src)));
console.log('woff2 →', out);
}Wire into package.json
Exampleprebuild runs automatically before npm run build (and next build). Commit the WOFF2 or gitignore it — your call.
{
"scripts": {
"prebuild": "node scripts/woff2.mjs",
"build": "next build"
}
}Google's reference CLI (woff2_compress)
ExampleThe C++ reference encoder. Fast for large batches; produces byte-for-byte the canonical WOFF2.
# one font woff2_compress src/fonts/Inter-Regular.ttf # → Inter-Regular.woff2 # whole folder for f in src/fonts/*.ttf; do woff2_compress "$f"; done mv src/fonts/*.woff2 public/fonts/
JAD runner HTTP API — no toolchain in CI
ExampleIf the runner is paired, POST the font to the local endpoint; it returns WOFF2. No Python, no native build. ttf-to-woff2 takes no options.
curl -sS -X POST http://127.0.0.1:9789/v1/tools/ttf-to-woff2/run \ -F 'files=@src/fonts/Inter-Regular.ttf' \ -o public/fonts/Inter-Regular.woff2
GitHub Actions — generate + size-budget gate
ExampleRun the converter, then fail the build if any output exceeds a budget or if a generated WOFF2 wasn't committed.
- name: Generate WOFF2
run: node scripts/woff2.mjs
- name: Fail if a WOFF2 was missing
run: git diff --exit-code public/fonts/
- name: Enforce size budget
run: |
for f in public/fonts/*.woff2; do
SIZE=$(stat -c%s "$f")
echo "$f = $SIZE bytes"
test "$SIZE" -le 153600 # fail if any weight > 150 KB
doneEdge 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.
CI image has no Python or native build tools
Use wawoff2 or the runnerfonttools/woff2_compress need Python or a C++ toolchain that minimal CI images lack. The wawoff2 npm package (pure JS/WASM) or the JAD runner's HTTP API both avoid that — same WOFF2 output, no apt-get. This is the most common reason teams switch engines.
Re-compressing a font that's already WOFF2
Wasted work — guard itIf your glob accidentally includes *.woff2, you'll decompress and re-compress for ~0% gain. Restrict the source glob to *.{ttf,otf} and treat WOFF2 as the build output, not an input.
Forgot to subset — WOFF2 still huge
Wrong leverWOFF2 compresses the bytes you keep; it doesn't drop glyphs. A full CJK or icon font stays large after compression. Add a subsetting step (pyftsubset/hb-subset, or the Font Subsetter runner endpoint) before the WOFF2 step. See Automate font subsetting in your build pipeline.
Build got slower after adding the step
Make it idempotentRe-compressing every font on every build is wasteful. Skip fonts whose source mtime/hash is unchanged (as the Node example does), or only run the step on production builds so local dev stays fast.
Generated WOFF2 not committed, deploy serves nothing
CI gapIf you .gitignore the WOFF2 but the deploy pipeline doesn't run the generator, the served @font-face 404s. Either commit the generated files, or ensure the deploy job runs the same converter before bundling. The git diff --exit-code guard catches the committed-files variant.
Variable font in the pipeline
SupportedVariable fonts compress to WOFF2 with the same step and no special handling — fvar/gvar survive the wrap, so axes still work. If you want a smaller static instance instead, instance first (Variable Font Freezer or fonttools varLib.instancer), then WOFF2-compress.
A .ttc collection in the source folder
Will fail — split firstNeither the browser tool nor a naive script handles a TrueType Collection directly. Split it into individual TTF/OTF faces (otc2otf, fonttools) before the WOFF2 step, or your converter throws Unsupported font format: ttc.
Server serves the WOFF2 with the wrong Content-Type
Deploy-config bugEven a perfect build can be undone by a server returning text/plain for .woff2. Add a deploy-side check (or static-host config) ensuring font/woff2. The bytes are still valid WOFF2 — verify with the Font Format Identifier.
Frequently asked questions
Which engine produces the same output as the browser tool?
The wawoff2 npm package — it's the exact library JAD's in-browser converter loads, just running in Node instead of WASM-in-the-tab. woff2_compress/fonttools use the same WOFF2 algorithm; differences are a few percent from Brotli quality/window settings, not from a different format.
Can I automate this without installing Python or native tools?
Yes — two ways. Use the wawoff2 npm package (pure JS/WASM, npm i wawoff2), or pair the JAD runner and call POST http://127.0.0.1:9789/v1/tools/ttf-to-woff2/run with the font as a multipart files field. Both avoid fonttools/woff2_compress native/Python deps.
Does the ttf-to-woff2 tool take any options in the API?
No. Its schema is empty (options: []). GET /api/v1/tools/ttf-to-woff2 confirms it. You POST a single font file and get a WOFF2 back — there are no quality, charset, or format parameters for this tool.
Should I commit the generated WOFF2 files?
Most teams do — it makes deploys reproducible and removes a build dependency. The cost is a slightly larger repo. If fonts change rarely, commit them; if they change often, .gitignore and ensure the deploy pipeline runs the generator. The git diff --exit-code guard works either way.
Can I convert at request time with an edge function instead?
Technically yes, but it's wasteful — the same bytes get re-compressed for every request. WOFF2 conversion is one-time work that benefits every visitor, so do it at build time and serve a static, cacheable file.
How do I fail CI when a font isn't optimised?
Run the converter in CI, then git diff --exit-code public/fonts/ to catch a missing committed WOFF2, and a per-file size-budget check (stat -c%s + test -le) to catch a regression. The cookbook above has a working GitHub Actions snippet with a 150 KB gate.
What about variable fonts in CI?
They convert with the same step — fvar/gvar survive the WOFF2 wrap, so the axes still work. If you'd rather ship a single static weight, instance the font first (instancer or Variable Font Freezer) and then WOFF2-compress.
Should I subset in the same pipeline?
For the smallest output, yes — subsetting drops unused glyphs and WOFF2 then compresses what's left. Run a layout-preserving subsetter (hb-subset/pyftsubset --layout-features='*') before the WOFF2 step. The full pattern is in Automate font subsetting in your build pipeline.
How do I skip conversion in dev mode?
Gate the script: only run it in production builds (if (process.env.NODE_ENV === 'production')), and in dev point @font-face at the source TTF directly. That keeps the dev feedback loop fast without a per-save compression pass.
Are my fonts uploaded anywhere when using the runner?
No. The @jadapps/runner runs the same processor locally on your machine and writes the output to disk — the font bytes never reach JAD's servers. The runner is the bridge that lets a script call the tool without the browser UI.
Is the build-time conversion lossless?
Yes. WOFF2 is a wrapper: the SFNT tables, outlines, metrics, kerning, and OpenType features are byte-equivalent after the browser decompresses. Only the optional metadata/private blocks can be dropped by some encoders. Safe to run unattended.
How small should each weight be?
A Latin text weight is typically 20–60 KB as WOFF2 (and 15–25 KB if subset to Latin Basic). CJK weights are far larger unless subset. Pick a size budget per weight (e.g. 150 KB for full Latin, 30 KB for a subset) and enforce it in CI so regressions are caught at PR time.
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.