Workers Static Assets
Serving static files from a standalone Worker with [assets], plus the gating and preview-URL traps
The Workers Static Assets Model
Workers Static Assets lets a standalone Worker serve a directory of static files (HTML, CSS, JS, images) directly from Cloudflare’s edge, while still running Worker code for dynamic requests. It is the successor to Cloudflare Pages for static + SSR sites: instead of a separate Pages project, you ship one Worker that owns both the asset directory and the request logic.
You configure it with an [assets] table in wrangler.toml:
name = "my-site"
main = "./dist/_worker.js"
compatibility_date = "2024-12-01"
[assets]
directory = "./dist"
binding = "ASSETS"
not_found_handling = "404-page"
run_worker_first = false
directory— the folder of static files to serve (your build output).binding = "ASSETS"— exposes the asset store to your Worker asenv.ASSETS, so the Worker can fetch an asset programmatically (env.ASSETS.fetch(request)). The name must match what your adapter/code expects.not_found_handling— what to serve when no asset matches (see below).run_worker_first— whether the Worker runs before the asset layer (see below).
ℹ️ Adapters generate this for you
Frameworks like Astro emit a dist/_worker.js entry and expect binding = "ASSETS". You usually just confirm the [assets] block matches the adapter’s expectations rather than writing the Worker from scratch.
run_worker_first — the Gating Trap
By default, run_worker_first = false. This means the asset layer is consulted first: for a GET/HEAD request, if a matching static file exists, Cloudflare returns it directly and the Worker script never runs. The Worker only executes when no asset matches.
That is exactly what you want for a normal site — static files are served fast, and the Worker handles only dynamic routes. But it silently breaks request gating.
If your Worker is meant to authorize or gate every request (for example, Basic Auth on a staging deploy, or an allowlist check on a preview host), the default ordering defeats it:
[assets]
directory = "./dist"
binding = "ASSETS"
not_found_handling = "404-page"
# Default false: a GET to a preview host returns 200 from the asset layer
# and never reaches the gate-wrapped worker -> the gate is silently bypassed.
run_worker_first = false
A GET to /index.html on a preview host returns 200 from the asset layer before the Worker runs, so the auth check never executes. The preview deploy is silently ungated — a real security hole, because it looks protected (the Worker code is there) but isn’t.
The fix is to force the Worker to run first:
[assets]
directory = "./dist"
binding = "ASSETS"
not_found_handling = "404-page"
# Worker runs on EVERY request first; it gates, then serves the asset
# itself via env.ASSETS.fetch(request) once the request is authorized.
run_worker_first = true
⚠️ run_worker_first = true is mandatory for per-request gating
If the Worker must authorize every request, run_worker_first = true is not optional. With the default false, matching assets are served before the Worker, so your gate is bypassed for any path that resolves to a static file. Set it to true and have the Worker serve assets via env.ASSETS.fetch() after the gate passes.
The Preview-URL Disappearance Trap
Per-deploy preview URLs (the *.workers.dev version-preview hosts emitted by wrangler versions upload --preview-alias) are controlled by preview_urls. The trap: preview_urls defaults to match workers_dev.
So the moment you set workers_dev = false to stop serving production on *.workers.dev, an omitted preview_urls also flips to false — and all per-deploy preview URLs silently disappear. This is the classic “why did my preview URL stop working?” surprise: you only changed the production route, but you lost previews too.
The fix is to set preview_urls = true explicitly:
name = "my-site"
main = "./dist/_worker.js"
compatibility_date = "2024-12-01"
# Don't serve production on *.workers.dev...
workers_dev = false
# ...but preview_urls defaults to match workers_dev, so an omitted value would
# also become false and kill ALL per-deploy preview URLs. Set it explicitly.
preview_urls = true
[assets]
directory = "./dist"
binding = "ASSETS"
not_found_handling = "404-page"
run_worker_first = false
💡 Keep top-level fields above [assets]
In TOML, any key after a table header is scoped into that table. If workers_dev / preview_urls sit below [assets], wrangler warns “Unexpected fields found in assets field” and silently ignores them. Keep these top-level fields above the [assets] table.
Not-Found Handling and SPA/SSG Fallback
not_found_handling decides what the asset layer serves when a GET matches no file:
"404-page"— servedist/404.html(typical for static-site generators). Good for SSG output where each route is a real file and unknown paths should show a 404 page."single-page-application"— servedist/index.htmlfor unmatched routes, so a client-side router can handle them. Use this for SPAs."none"— return a bare 404 with no body.
[assets]
directory = "./dist"
binding = "ASSETS"
# SSG: unmatched GETs serve dist/404.html
not_found_handling = "404-page"
📝 The asset layer only handles GET/HEAD
not_found_handling applies to GET/HEAD requests. POST and other methods are never served from the asset layer — they always reach the Worker (when run_worker_first allows it). So a POST /api/... is unaffected by not_found_handling.
.assetsignore
A .assetsignore file inside the asset directory lists files to exclude from the public asset store, much like .gitignore. The common use is to keep the Worker entry and its internal bundle from being served as downloadable files:
# dist/.assetsignore
_worker.js
_worker.js.map
Without this, dist/_worker.js would be publicly fetchable as a static asset. Build/deploy tooling often generates .assetsignore into dist/ at deploy time rather than committing it, since its contents depend on the adapter’s output filenames.
Summary
| Field | Default | Set it when |
|---|---|---|
binding | — | Always; your adapter reads env.ASSETS |
not_found_handling | "none" | SSG -> "404-page"; SPA -> "single-page-application" |
run_worker_first | false | The Worker must gate/authorize every request -> true |
workers_dev | true | Stop serving production on *.workers.dev -> false |
preview_urls | matches workers_dev | Always set explicitly so previews survive workers_dev = false |