Static Files and Caching

Everything under src/static/ in your repository is copied to the site root, so src/static/js/main.js is served at /static/js/main.js. On the live site those URLs come back with a one-year immutable cache header. That is what makes a repeat visit fast, and it is also why a file you changed and pushed can keep serving its old contents to everyone who has already seen it.

This page is about that trade. None of it is plan-gated — the headers below are emitted for every site on every plan, including Free.

The exact header

The build emits a Cloudflare _headers file at the site root. Here is the whole of it, as a public build produces it:

https://production-leed-ai.leed.workers.dev/*
    X-Robots-Tag: noindex

/*
    Content-Security-Policy: frame-ancestors 'none'
    X-Content-Type-Options: nosniff
    Referrer-Policy: strict-origin-when-cross-origin

/stubs/*
    X-Robots-Tag: noindex

/static/*
    Cache-Control: public, max-age=31536000, immutable
    X-Robots-Tag: noindex

max-age=31536000 is 365 days, and immutable is the part that bites: it tells the browser the bytes at this URL will never change, so it must not revalidate — no conditional request, no 304, nothing. Once a visitor’s browser has fetched /static/js/main.js, it will not ask your server about that URL again for a year.

That is the correct behavior for a URL whose contents genuinely never change. The whole of this page is about making sure the URLs you own actually have that property.

Preview sites do not get it

The /static/* block is emitted only on a public build. On preview it is absent entirely, so /static/* there is served with no cache header at all and no X-Robots-Tag.

This is the single most confusing symptom in this whole area, because it inverts your intuition about what to trust. You change a stylesheet, push, look at preview, and it is correct. You promote, look at live, and it is stale. Preview is not lying — it is simply the environment in which caching is switched off, so it can never reproduce the problem.

$ curl -sI https://staging.example.com/static/js/main.js | grep -i 'cache-control\|x-robots'
(no output — neither header is set)

No Cache-Control, so the browser applies its own heuristics and will generally revalidate. Your change shows up on the next reload. Verified against a real preview build’s _headers, which contains a /*, a /stubs/* and two host-scoped noindex blocks — and no /static/* block at all.

The whole preview _headers file, for comparison
https://staging.example.com/*
    X-Robots-Tag: noindex

https://example-preview.example.workers.dev/*
    X-Robots-Tag: noindex

/*
    Content-Security-Policy: frame-ancestors 'none'
    X-Content-Type-Options: nosniff
    Referrer-Policy: strict-origin-when-cross-origin

/stubs/*
    X-Robots-Tag: noindex

Two differences from the public file. The preview build adds a noindex block for the preview domain itself, so a leaked preview URL never competes with your live pages in search. And the /static/* block — both the cache header and its X-Robots-Tag — is simply not there.

What Leed versions for you, and what it does not

There are three kinds of file under /static/, and only two of them get a cache-busting mechanism.

AssetWho versions itMechanismWhat you do
Tailwind stylesheetLeedContent hash in a query string — /static/css/tailwind.css?v=<hash>Nothing
Leed’s own JavaScriptLeedVersion number in the filename — /static/js/l.<name>.<version>.min.jsNothing
Your JS, CSS, images and data filesYouA ?v= query string you write and maintainBump it on every change
WebfontsLeedDownloaded once into static/webfonts/, skipped when already presentNothing
CMS logo and faviconThe CMSISO timestamp in the filename — logo-<timestamp>.<ext>Nothing

The Tailwind stylesheet

Your compiled CSS is written once per build to /static/css/tailwind.css, and every template references it through a path that already carries the hash of its own contents:

<link href="/static/css/tailwind.css?v=8f3a1c92" rel="stylesheet">

Change any CSS anywhere in tailwind/, and the compiled output changes, and the hash changes, and the URL changes. You never touch it. How the Tailwind pipeline produces that file is documented with the rest of the styling layer.

One detail that surprises people auditing a build: this stylesheet is written directly to disk in a post-build hook rather than passing through Eleventy’s template pipeline, so it is not minified in any build mode, debug or otherwise. Your HTML, XML, JSON and JavaScript are minified on a non-debug build; tailwind.css is not.

Leed’s own JavaScript

Leed’s client scripts carry their version in the filename and bump it automatically when their source changes:

/static/js/l.docs.22.min.js
/static/js/l.utils.31.min.js
/static/js/l.zoomable.5.min.js

A new version is a new URL, so the immutable header is exactly right for them. You never reference these by hand — the partials that need them emit the versioned path.

Your files

The ?v= convention

Add a query string to the URL and bump it in the same commit that changes the file. The query string is not read by anything — its entire purpose is to be a different URL.

<script src="/static/js/main.js?v=2"></script>
<link href="/static/css/print.css?v=4" rel="stylesheet">
<img src="/static/images/hero.jpg?v=3" alt="">

The discipline that makes this work is a single rule: the version bump goes in the same commit as the file change. A change committed without the bump is a change nobody sees, and you will spend an afternoon on it.

The alternative, if you prefer it, is to rename the file — main.v2.js — which gives you the same new URL with no query string. It has one wrinkle worth knowing: the old file stays live at its old URL until you delete it, so a template you forgot to update keeps working and keeps serving the old behavior silently. The query-string form fails more loudly, which on balance is what you want.

What is already in static/ that you did not put there

static/webfonts/

Leed downloads the bundled font families it detects in your compiled CSS into src/static/webfonts/ at build time, along with Font Awesome Pro. Both check for an existing install first — a version marker file for Font Awesome, a directory-presence check for each font family — so the download happens once and subsequent builds skip it.

You never commit these. That also means the repository’s 500 KB file-size ceiling never comes near them: the ceiling applies to files you add, and these arrive after validation has already run. Most sites never add a font file at all, because the bundled families cover the common cases.

Written by the CMS when you upload a logo or favicon in Settings, named with an ISO timestamp so each upload is a new URL. They are committed to your repository and they are read-only — an edit to one is rejected at commit time. Uploading a replacement is the supported path, and it is documented with site identity and branding.

Note the hyphen in those globs. src/static/images/logo-hero.png matches logo-* and will be rejected even though you wrote it; a src/static/images/logo/ directory of your own artwork does not match and is entirely yours.

static/js/l.*.js

Leed’s trackers and client scripts. Gitignored, written at build time, swept at cleanup — which is the subject of the next section, and the reason you must never name a file of your own to match.

Files that never survive a build

Every build copies Leed’s own templates and assets into your working tree, and every build sweeps them out again when it finishes. The sweep is pattern-based, and it does not check who wrote the file.

PatternWhat it isRemoved when
src/_includes/leed/Leed’s entire partial setEnd of every build
leed-*.hbsSite-root templates — sitemaps, feeds, _headers, _redirects, unsubscribe stubsEnd of every build
tailwind-*.cssReserved build artifact patternEnd of every build
leed-*-data.jsonBuild-time data, such as the form partial’s country listEnd of every build
l.*.*.min.jsLeed’s versioned client scriptsEnd of every build
leed-*.pngReserved build artifact patternEnd of every build
src/_data/eleventyComputed.jsThe computed data layerEnd of every build

There is a second, narrower sweep that runs before Eleventy rather than after it. Two templates Leed used to ship, leed-stubs.hbs and leed-cta.hbs, are pruned at the start of every build. A stale copy left in an old repository by a previous release would otherwise be picked up and built — and one of them paginates over a collection that no longer exists, so the build throws before it ever reaches the cleanup step that would have removed it. If you ever see a build fail on a file you cannot find in git status, this is the shape of that problem, and running a current build is the fix.

The other headers your site sets

Caching is one block of the _headers file. Here is the rest, so you leave knowing what a visitor actually receives.

Path patternHeaderValueWhere
/*Content-Security-Policyframe-ancestors 'none'Both
/*X-Content-Type-OptionsnosniffBoth
/*Referrer-Policystrict-origin-when-cross-originBoth
/stubs/*X-Robots-TagnoindexBoth
/static/*Cache-Controlpublic, max-age=31536000, immutablePublic only
/static/*X-Robots-TagnoindexPublic only
your preview domain, /*X-Robots-TagnoindexPreview only
the workers.dev domain, /*X-Robots-TagnoindexBoth

Two things to read out of that table. The whole preview site is noindex, which is why a preview URL that leaks into a link never competes with your live pages in search. And the workers.dev hostname is noindex on the public build too — your live site is reachable at both its custom domain and its workers.dev address, and only the custom domain is meant to be indexed.

None of these are configurable from the repository. Editing the generated _headers file is pointless because the file does not exist in your repository — it is emitted at build time from a Leed-owned template and swept afterwards. The complete set of response headers your visitors receive covers the ones set outside this file as well.

Where large assets belong instead

Nothing on this page makes src/static/ a good home for a video, a PDF or a 4 MB photograph. The repository rejects any file at or over 500 KB, and it rejects video, audio, office documents and archives outright — the full denylist and the size ceiling are on the validation page.

Those files go in the asset library, where they are served from the CDN with their own delivery rules rather than out of your git repository. Asset delivery and protection covers how that works, and images that need multiple sizes get responsive variants generated for them instead of being hand-resized into static/.

If what you are adding is a script or a stylesheet rather than an asset, there are exactly two supported injection points for getting it onto the page.

ESC