How to auto-generate font preload tags in your build
- Step 1Emit a manifest from your build with hashed font URLs — Vite/webpack/Next already write an asset manifest, or generate one when you hash fonts with [font-fingerprinter](/font-tools/font-fingerprinter). Shape it so each entry has the final URL and a `critical` flag: `{ "inter-400": { "url": "/fonts/inter-400.9d38c18.woff2", "critical": true } }`.
- Step 2Pair the JAD runner once — Install and pair `@jadapps/runner` so the local API is reachable at `http://127.0.0.1:9789`. This exposes the same processing engine the web UI uses, callable over HTTP, with nothing leaving your machine.
- Step 3Confirm the option schema — `GET http://127.0.0.1:9789/api/v1/tools/preload-tag-builder` (or the hosted `/api/v1/tools/preload-tag-builder`) returns the tool's options — a single required `preloadUrls` textarea. This is the only field you send.
- Step 4Build the newline-joined URL list from critical entries — Filter the manifest to `critical: true`, map to `url`, and `join("\n")`. Keep this list to the 1–2 fonts on the LCP path — the builder has no cap and will emit a tag for every URL you pass.
- Step 5POST to the runner and capture the HTML — `POST http://127.0.0.1:9789/v1/tools/preload-tag-builder/run` with `{ "preloadUrls": "<joined>" }`. The response is the HTML fragment (leading comment + one `<link rel="preload">` per URL). Write it to `dist/preload.html`.
- Step 6Inject the fragment into your head template and gate on drift — Have your build inject `dist/preload.html` into `<head>` above the stylesheet. In CI, fail the job if a previously-critical font is missing from the new manifest (a removed font whose preload would 404), or if the generated fragment differs from what's committed when you expect it not to.
Runner API surface for this tool
The preload builder is generative (no file input), so the runner call is a plain JSON POST. Endpoints mirror the runner pattern used across the font tools.
| Call | Method + path | Body / returns |
|---|---|---|
| Read the option schema | GET /api/v1/tools/preload-tag-builder | Returns the single preloadUrls (textarea, required) option |
| Generate tags (local runner) | POST http://127.0.0.1:9789/v1/tools/preload-tag-builder/run | Body { "preloadUrls": "a.woff2\nb.woff2" } → HTML fragment |
| Input field | preloadUrls | Newline-separated URLs; trimmed; blank lines dropped; ≥1 required |
| Output | text/html | Comment header + one <link rel="preload" as="font" type="..." href="..." crossorigin> per URL |
| Failure | Empty input | Throws Enter at least one font URL. |
Where each piece of the pipeline comes from
The preload builder owns only the URL→tag step. Hashing, conversion, and the @font-face are other tools — don't expect this one to do them.
| Pipeline step | Tool / source | Output it feeds forward |
|---|---|---|
| Convert to WOFF2 | ttf-to-woff2 | The .woff2 files you preload |
| Content-hash filenames | font-fingerprinter / bundler | Hashed URLs + manifest |
| Generate preload tags | preload-tag-builder (this tool) | <link rel="preload"> fragment |
| Generate matching @font-face | font-face-generator | @font-face whose src URL must match the preload |
| Pick font-display | font-display-strategy | font-display value for the swap behaviour |
Cookbook
Drop-in snippets to generate preload tags from a build manifest. They assume the runner is paired at 127.0.0.1:9789 and a manifest whose critical entries carry the final hashed URL.
Node: manifest → runner → dist/preload.html
ExampleRead critical font URLs from the manifest, POST the newline-joined list to the runner, and write the returned fragment. No font bytes are sent — only URLs.
// scripts/gen-preload.mjs
import { readFileSync, writeFileSync } from 'node:fs';
const manifest = JSON.parse(readFileSync('dist/fonts.manifest.json', 'utf8'));
const urls = Object.values(manifest)
.filter((e) => e.critical)
.map((e) => e.url)
.join('\n');
const res = await fetch('http://127.0.0.1:9789/v1/tools/preload-tag-builder/run', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ preloadUrls: urls }),
});
const html = await res.text();
writeFileSync('dist/preload.html', html);
console.log('Wrote dist/preload.html');Example manifest with a critical flag
ExampleOnly entries marked critical become preload tags. Everything else still has an @font-face but isn't preloaded — keeping the LCP path lean.
// dist/fonts.manifest.json
{
"inter-400": { "url": "/fonts/inter-400.9d38c18.woff2", "critical": true },
"inter-700": { "url": "/fonts/inter-700.9d38c18.woff2", "critical": false },
"jetbrains-mono": { "url": "/fonts/jbmono.9d38c18.woff2", "critical": false }
}
// → builder input becomes just:
/fonts/inter-400.9d38c18.woff2Generated fragment injected into the head template
ExampleThe runner returns the comment header plus one tag per critical URL. Your templating step splices it above the stylesheet.
// dist/preload.html (returned by the runner)
<!-- Add inside <head>, BEFORE the stylesheet that uses these fonts.
crossorigin is required for browsers to reuse the preloaded font. -->
<link rel="preload" as="font" type="font/woff2" href="/fonts/inter-400.9d38c18.woff2" crossorigin>
<!-- head.html template -->
<head>
{{> preload.html }}
<link rel="stylesheet" href="/styles.css">
</head>GitHub Actions step
ExampleRun the generator after the build, then fail if the committed fragment is stale (catches the case where someone hand-edited preloads and forgot to regenerate).
- name: Generate font preload tags
run: |
node scripts/gen-preload.mjs
git diff --exit-code dist/preload.html \
|| (echo 'preload.html is stale — regenerate and commit' && exit 1)Drift guard: critical font removed from manifest
ExampleIf a font marked critical last build is gone this build, its preload would 404. Fail fast in the script rather than ship a broken hint.
const expectedCritical = new Set(['inter-400']); // committed expectation
const actualCritical = new Set(
Object.entries(manifest).filter(([, e]) => e.critical).map(([k]) => k),
);
for (const k of expectedCritical) {
if (!actualCritical.has(k)) {
throw new Error(`Critical font '${k}' missing from manifest — preload would 404`);
}
}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.
Hashed filename changed but the preload didn't
Stale hint — 404 + double fetchThe whole reason to automate: if you hand-wrote inter.abc.woff2 and the new build emits inter.def.woff2, the browser preloads the dead URL, fails, then fetches the real one from @font-face. Generating the tag from the manifest each build makes drift structurally impossible — both sides read the same url.
Empty critical set
Error: Enter at least one font URL.If no manifest entry is marked critical, the joined string is empty and the runner throws Enter at least one font URL. Decide per project whether that's a failure (you expect at least one critical font) or a no-op (skip the POST when the list is empty). The script, not the builder, owns that policy.
Hashed URL with a query-string cache-buster
By design — wrong type emittedIf your manifest uses /inter.woff2?v=abc rather than a hashed *filename*, the builder's extension detector reads woff2?v=abc, matches nothing, and emits type="font/ttf". Prefer hashing the filename (inter.abc.woff2) over a query string; if you must use ?v=, post a clean URL to the builder and re-append the buster afterwards.
Preload tags placed after the stylesheet
Suboptimal — late discoveryInjecting the fragment *below* the <link rel="stylesheet"> lets the render-blocking CSS be discovered first, defeating the parallelism. The generated comment says 'BEFORE the stylesheet' for a reason — wire your template to splice the fragment high in <head>.
Per-route critical fonts
Supported via the script, not the toolDifferent routes have different LCP fonts (a marketing serif vs. an app sans). The builder takes one flat URL list per call, so run it once per route with that route's critical subset and inject the right fragment per template. The branching lives in your build, not in the tool's options.
Font removed from the manifest
Handled — tag simply isn't emittedIf a font is dropped from the manifest, it won't appear in the joined URL list and no preload tag is generated for it — clean by construction. The lingering risk is a CDN/edge cache still serving old HTML with the deleted font's preload; purge the HTML cache on deploy.
Runner not running in CI
Fail — connection refusedThe POST to 127.0.0.1:9789 requires the paired runner to be up in the CI environment. If it isn't, the fetch is refused and the build should fail loudly rather than ship without preloads. Start the runner as a CI service step, or run the tiny URL→tag transform inline if you can't host the runner there.
Too many fonts marked critical
Allowed — but it hurts LCPThe builder emits a tag for every URL with no warning or cap. Marking five fonts critical produces five preloads that fight for the first bytes and can regress LCP. Keep the critical set to the 1–2 fonts the LCP element actually uses; enforce that in the manifest, since the tool won't.
Frequently asked questions
Does the builder upload font files in CI?
No. It's generative — the only input is preloadUrls, a newline-separated list of URLs. No font bytes are read or sent; you pass strings and get back HTML. That keeps the CI call cheap and keeps fonts on your machine.
What's the exact runner request?
POST http://127.0.0.1:9789/v1/tools/preload-tag-builder/run with JSON body { "preloadUrls": "/fonts/a.woff2\n/fonts/b.woff2" }. The response is the HTML fragment (comment header + one <link rel="preload"> per URL). Read the option schema first with GET /api/v1/tools/preload-tag-builder.
How do I mark fonts as critical?
That's a convention in *your* manifest, not a builder feature. Add a critical: true flag to the 1–2 LCP-path fonts, filter on it in the script, and pass only those URLs to the builder. The builder emits exactly the URLs you give it.
Can it generate prefetch or preconnect tags for the build?
No — it emits rel="preload" only. For prefetch/preconnect in your pipeline, generate the preload tags and post-process them (swap preload→prefetch) or template the hints separately. See preload vs prefetch vs preconnect.
How do I handle per-page critical fonts?
Run the builder once per route with that route's critical URL subset, and inject the resulting fragment into the matching head template. The tool takes one flat list per call, so the per-route branching lives in your build script.
What happens when a font is removed?
It drops out of the manifest, so it's never in the URL list and no preload tag is generated — clean by construction. The only stale-state risk is cached HTML at the CDN/edge still referencing the old preload; purge the HTML cache on deploy.
Why not just hand-write the preload once and forget it?
Because content-hashing changes the filename every time the font bytes change. A hand-written tag silently points at the previous hash, so the browser preloads a dead URL and then re-fetches the real one. Generating from the manifest each build eliminates that drift.
Does the builder verify the URLs are reachable?
No. It does string processing only — trim, split on newlines, derive type from the extension, wrap in a tag. It never fetches the URL. Reachability and correctness of the path are your build's responsibility; add a drift guard (see the cookbook) to catch removed criticals.
Can I avoid the runner and just call the transform myself?
The transform is simple enough to inline (<link rel="preload" as="font" type="${type}" href="${url}" crossorigin> with the same extension→type mapping), but using the runner keeps you on the exact same logic as the UI and the hosted tool, so behaviour can't drift between environments. For CI that can't host the runner, inlining is a reasonable fallback.
How do I get the WOFF2 URLs in the first place?
Convert your sources with ttf-to-woff2, hash the filenames with font-fingerprinter (or your bundler), and write both into the manifest. The preload pipeline then reads the hashed URLs straight from there.
Do I need a matching @font-face for each preload?
Yes — a preload with no matching @font-face fetch triggers Chrome's 'preloaded but not used' warning and wastes bandwidth. Generate the @font-face from the same manifest with font-face-generator and ensure its src URL is byte-identical to the preload's href.
Is this the same pattern as automating subsetting?
Yes — same shape: read inputs, call the runner's local API, write artefacts, gate the job. The automate font subsetting guide covers the subsetting half of the pipeline that produces the smaller WOFF2s you then preload.
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.