How to automate font base64 encoding in your build pipeline
- Step 1Author CSS with normal url() references — Write `@font-face` declarations using `url("/fonts/brand.woff2")` during development. No base64 noise in source control, normal hot-reload, normal diffs. The inlining is a deploy-time transform applied to a copy, not to your source.
- Step 2Encode with a non-wrapping encoder — In Node, `fs.readFileSync(path).toString('base64')` produces a single unbroken line — the correct form. With openssl use `openssl base64 -A` (the `-A` disables 76-column wrapping); with GNU coreutils `base64` use `base64 -w 0`. Never ship a wrapped payload: `lightningcss` will silently drop the whole rule.
- Step 3Use the right MIME and format() keyword — Map the font's real format to its MIME and CSS keyword: WOFF2 to `font/woff2` / `format("woff2")`, WOFF to `font/woff` / `format("woff")`, OTF to `font/otf` / `format("opentype")`, TTF to `font/ttf` / `format("truetype")`. An empty or `text/plain` MIME makes the browser refuse to parse the bytes as a font.
- Step 4Swap url() for the data URI and write to dist/ — Read the built CSS, regex-replace each `url("...local.woff2")` with the encoded data URI, and emit to `dist/`. Keep the data URI double-quoted (`url("data:...")`) to match what JAD's encoder produces and to sidestep unquoted-url parser quirks.
- Step 5Add a CI size budget — Base64 inflates by 1.333x and stacks fast. Add a check that fails the build if the inlined CSS exceeds your budget (say 200 KB) so a stray full-family inline doesn't ship a multi-MB stylesheet. Warn separately if any single payload exceeds a per-font cap.
- Step 6Validate the artefact, not just the source — After minification, grep the built CSS for `data:font` and confirm each `@font-face` still has its `src` (lightningcss drops rules with whitespace in the payload, leaving only `font-family`). For a quick manual check, paste a block into JAD's [base64 font validator](/font-tools/guides/base64-font-edge-cases-quirks).
Non-wrapping base64 across toolchains
The single most important build rule: emit base64 on one line. These are the flags that disable the default 76-column MIME wrapping in each tool.
| Tool | Command | Wraps by default? |
|---|---|---|
| Node | fs.readFileSync(p).toString('base64') | No — never wraps; safe as-is |
| openssl | openssl base64 -A -in font.woff2 | Yes (76 cols) — -A disables it |
| GNU coreutils | base64 -w 0 font.woff2 | Yes (76 cols) — -w 0 disables it |
| Python | base64.b64encode(data).decode() | No — b64encode returns one line |
| JAD font-to-base64 | Drop file, copy the block | No — chunked btoa, single line, full @font-face |
MIME and format() map for the encoder
Hard-code these in your script. The MIME lives inside the data URI; the format() keyword is the separate CSS hint — note opentype/truetype differ from the file extension.
| Source format | MIME (in data URI) | CSS format() keyword |
|---|---|---|
| WOFF2 | font/woff2 | woff2 |
| WOFF | font/woff | woff |
| OTF (OpenType/CFF) | font/otf | opentype |
| TTF (TrueType) | font/ttf | truetype |
Bundler auto-inline thresholds
Webpack and Vite auto-inline imported assets under a size limit as data URIs. Tune the threshold deliberately — it's a config option, not magic.
| Bundler | Config key | Default threshold |
|---|---|---|
| Vite | build.assetsInlineLimit | 4096 bytes (4 KB) |
| Webpack 5 | module.rules[].parser.dataUrlCondition.maxSize | 8192 bytes (8 KB) |
| Standalone Node script | your own size filter | whatever you set — works regardless of bundler |
Cookbook
Recipes for build-time inlining. Each is minifier-safe (single-line) and standard-alphabet. Adapt paths and the size threshold to your project.
Minimal Node encoder — one font to a data URI
ExampleThe core operation: read, encode single-line, wrap. toString('base64') never line-wraps, so the output survives lightningcss.
import { readFileSync } from "node:fs";
function toDataUri(path, mime) {
const b64 = readFileSync(path).toString("base64"); // single line
return `data:${mime};base64,${b64}`;
}
const uri = toDataUri("src/fonts/Brand.woff2", "font/woff2");
console.log(`url("${uri}") format("woff2")`);Swap url() references in a built CSS file
ExampleRead the built CSS, replace each local .woff2 reference with its inlined data URI, write to dist/. Keeps source CSS untouched.
import { readFileSync, writeFileSync } from "node:fs";
import { resolve, dirname } from "node:path";
const cssPath = "dist/styles.css";
let css = readFileSync(cssPath, "utf8");
css = css.replace(/url\("([^"]+\.woff2)"\)/g, (_, ref) => {
const file = resolve(dirname(cssPath), ref);
const b64 = readFileSync(file).toString("base64");
return `url("data:font/woff2;base64,${b64}")`;
});
writeFileSync(cssPath, css);Selective inlining — critical weight only
ExampleInline only the fonts you actually want inline (e.g. the critical Regular), leave everything else as external requests for cacheability.
const INLINE = new Set(["Brand-Regular.woff2"]);
css = css.replace(/url\("([^"]+\.woff2)"\)/g, (m, ref) => {
const name = ref.split("/").pop();
if (!INLINE.has(name)) return m; // leave external
const b64 = readFileSync(resolve("dist", ref)).toString("base64");
return `url("data:font/woff2;base64,${b64}")`;
});CI size budget gate
ExampleFail the build if the inlined CSS blows past budget. Cheap insurance against accidentally inlining a full family.
import { statSync } from "node:fs";
const BUDGET = 200 * 1024; // 200 KB
const size = statSync("dist/styles.css").size;
if (size > BUDGET) {
console.error(`CSS ${(size/1024).toFixed(0)}KB > budget 200KB`);
process.exit(1);
}openssl / coreutils one-liners (non-wrapping)
ExampleWhen you'd rather shell out than write Node. The -A / -w 0 flags are mandatory — without them the 76-column wrap breaks lightningcss.
# openssl: -A = no line wrapping
openssl base64 -A -in Brand.woff2 -out Brand.b64
# GNU coreutils: -w 0 = no line wrapping
base64 -w 0 Brand.woff2 > Brand.b64
# build the data URI prefix
printf 'url("data:font/woff2;base64,%s") format("woff2")' "$(cat Brand.b64)"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.
76-column-wrapped base64 silently drops the rule
Rule dropped — silent failureIf your encoder wraps at 76 columns (RFC 2045 MIME default — openssl base64 and base64 without flags both do), lightningcss strips the whole @font-face and leaves only font-family. No warning; the font vanishes after a Next.js or Tailwind v4 build. Always emit single-line: toString('base64'), openssl base64 -A, or base64 -w 0.
URL-safe alphabet passes the minifier but breaks the browser
atob throws Invalid characterIf your encoder emits URL-safe base64 (- and _ instead of + and /), lightningcss passes it through unchanged, but the browser's atob throws Invalid character and the font fails. The data: scheme requires the standard alphabet (RFC 4648 §4). Never pass a --url-safe/urlsafe flag for font CSS — toString('base64') and b64encode already use the standard alphabet.
Empty or text/plain MIME makes the font invisible
200 OK, font never rendersAn empty MIME defaults to text/plain; the browser downloads the data URI but refuses to interpret the bytes as a font, with no console error. Always emit a real font/* type. Build the MIME from the font's actual format, not the file extension — a .ttf that's really OpenType should be font/otf.
Runtime encoding wastes CPU on a constant
Anti-patternEncoding the font per-request (in an Express handler, a serverless function, a render loop) recomputes a byte-identical string every time and adds latency. Base64-encode once at build time and serve the cached artefact. The only legitimate runtime case is a genuinely user-supplied font, which is rare.
Inlining inflates the CSS past your performance budget
Budget breachA full multi-weight family inlined into one render-blocking CSS file can balloon it to hundreds of KB. Add a CI size gate and prefer selective inlining (critical weight only). For the web, an external preloaded WOFF2 is almost always faster — see base64 vs external files.
TTF base64 gzips worse than the raw binary
Format-dependentBase64'd TTF can gzip ~17% larger than gzipping the raw TTF, because TTF has compressible structure that base64 scrambles; WOFF2 base64 stays within ~1% because WOFF2 is pre-compressed. Convert TTF to WOFF2 before inlining with ttf-to-woff2, then encode the WOFF2.
CSP font-src directive without data: blocks the result
Font request rejectedIf your app sends a Content-Security-Policy with font-src (or only default-src), the inlined font is blocked unless data: is listed. The CSS parses; the font silently falls back. Add font-src 'self' data: — and remember to update any per-environment CSP your CI deploys.
Source maps blow past the devtools size cap
Tooling side effectEmbedding inlined fonts in a CSS source map can push it past the ~10 MB limit devtools will load, silently breaking your maps. Strip data URIs from source-map inputs (postcss-discard-sourcemap-data or equivalent) or build the font CSS as a separate, source-map-disabled file.
Regex misses url() with single quotes or no quotes
Incomplete replacementA replace pattern matching only url("...") skips url('...') and unquoted url(...). If your source CSS mixes quoting styles, broaden the pattern or normalise the CSS first, or some fonts stay external silently. JAD's encoder always emits the double-quoted form, so standardising on it simplifies the regex.
Inlining a variable font defeats its purpose
AvoidVariable fonts bundle many weights into one file to be fetched once and cached. Inlining a 100-200 KB variable font pays the +33% penalty on a large, render-blocking, non-cacheable payload. Keep variable fonts external and preloaded; inline only static subsets generated with font-subsetter.
Frequently asked questions
Why build-time and not runtime?
Base64 of a fixed font is a constant — encoding it per request recomputes the same string and adds CPU and latency for no benefit. Encode once during the build, write the artefact, and serve it cached. Reserve runtime encoding for genuinely user-supplied fonts, which almost never happens in practice.
Why does my @font-face vanish after the Next.js build?
Almost certainly a line-wrapped base64 payload. lightningcss (which Next.js and Tailwind v4 use) silently drops any @font-face whose base64 contains whitespace, keeping only font-family. Emit single-line base64 — toString('base64') in Node, openssl base64 -A, or base64 -w 0 — and the rule survives.
Should I gzip / brotli the CSS afterwards?
Yes — your server should compress text responses regardless. For WOFF2 base64, gzip recovers nearly all the +33% inflation (within ~1% of the raw binary). For TTF base64 it recovers much less and can even be worse than the raw font, which is another reason to inline WOFF2 only.
What about Webpack / Vite asset-inline plugins?
Both auto-inline imported assets under a size threshold (build.assetsInlineLimit in Vite, default 4 KB; dataUrlCondition.maxSize in Webpack 5, default 8 KB). Same idea, framework-specific. The standalone Node recipe here works with any bundler and gives you exact control over which fonts inline.
Can I inline only some fonts?
Yes — filter by filename or size in the replace step. The common pattern is to inline the one critical weight and leave the rest external so they stay cacheable and load in parallel. The selective-inlining recipe above shows the filter.
Which MIME and format() keyword do I hard-code?
WOFF2 to font/woff2 / format("woff2"), WOFF to font/woff / format("woff"), OTF to font/otf / format("opentype"), TTF to font/ttf / format("truetype"). Note opentype/truetype are the CSS spellings, not the extensions. Drive the MIME from the actual format, not the filename.
How do I revert if base64 breaks production?
Keep the source CSS with normal url() references untouched in version control. The inlining is a deploy-time transform on a copy, so reverting is simply not running the inline step on the next deploy — your source already works with external fonts.
How do I verify the built artefact rather than the source?
After minification, grep the output for data:font and confirm each @font-face still has a src (a dropped payload leaves only font-family). Add this as a CI assertion. For a manual spot-check, paste a block into the base64 font validator.
Can I drive JAD's tool from CI instead of writing a script?
Yes. GET /api/v1/tools/font-to-base64 returns the (option-less) schema; pair the @jadapps/runner once and POST the font to 127.0.0.1:9789/v1/tools/font-to-base64/run. The runner returns the same quoted, single-line, swap-defaulted @font-face block the browser tool produces, so it slots into a pipeline directly.
Should I keep the data URI quoted?
Yes — emit url("data:...") with double quotes, matching JAD's encoder. Quoting is valid CSS and avoids unquoted-url() parser quirks. Whatever you do, keep the base64 itself on one line with the standard alphabet.
Does the build step change the font's bytes?
No. Base64 is a reversible transport encoding; decoding yields byte-identical font data. Hinting, kerning, OpenType features, and color tables are all preserved. The build step only changes how the bytes travel inside the CSS, not the font itself.
Is there a size cap I should worry about in the encoder itself?
JAD's browser tool caps the input font at 5 MB (Free), 50 MB (Pro), 200 MB (Pro + Media), or 1 GB (Developer). A standalone script has no such cap, but your real constraint is the CSS performance budget — inline small critical subsets, not whole families. Gate it in CI.
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.