zudo-cloudflare-wisdom
GitHub repository

Type to search...

to open search from anywhere

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 as env.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" -- serve dist/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" -- serve dist/index.html for 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.

An SSR Index Plus a 404 Page Breaks the Homepage for Browsers Only

not_found_handling is applied only to navigation requests -- ones carrying sec-fetch-mode: navigate, which every browser sends when a person opens a URL and which curl never sends. Non-navigation requests that match no asset fall through to the Worker instead.

That distinction is invisible until three ordinary choices line up:

  1. The index route is server-rendered (prerender = false), so the build emits no dist/index.html.

  2. not_found_handling = "404-page".

  3. The project has a 404 page, so dist/404.html exists for the asset layer to serve.

Now a browser hitting / matches no asset, gets answered from not_found_handling, and receives the 404 page -- the Worker never runs. The same URL under curl falls through to the Worker and renders perfectly:

curl bare                            -> 200  <title>My Site</title>
curl -H 'sec-fetch-mode: navigate'   -> 404  <title>Not found</title>
headless browser                     -> 404

The site is broken for every human visitor and green in every HTTP-level check.

Removing the 404 page "fixes" it by accident -- do not rely on that

A sibling project with the same SSR-index config worked only because it had no 404 page: with no dist/404.html to serve, requests fell through to the Worker. It was one added file away from the same outage. If your SSR-index app currently works, confirm why before assuming it is safe.

The fix is to run the Worker first for the routes it owns. Newer wrangler accepts a pattern list, so static assets keep being served directly:

[assets]
directory = "./dist"
binding = "ASSETS"
not_found_handling = "404-page"
# `/` is server-rendered and has no static file; the API routes are the
# Worker's too. Everything else (e.g. /assets/*) still bypasses the Worker.
run_worker_first = ["/", "/api/*"]

run_worker_first = true also works and is simpler, at the cost of invoking the Worker for every asset request.

Verify with a request that actually carries the header -- wrangler dev reproduces this faithfully:

curl -H 'sec-fetch-mode: navigate' http://127.0.0.1:8787/        # expect 200
curl -H 'sec-fetch-mode: navigate' http://127.0.0.1:8787/nope    # expect the real 404
curl http://127.0.0.1:8787/assets/styles-abc123.css              # still served directly

fetch() cannot send this header, so a Node smoke test cannot catch it

Sec- prefixed names are forbidden header names in the Fetch spec, so undici -- and therefore Node's global fetch() -- drops them silently. The request goes out as an ordinary one and gets the falling-through 200:

node fetch + sec-fetch-mode  -> 200   (header dropped) curl       + sec-fetch-mode  -> 404   (header sent)

A fetch-based post-deploy check therefore exercises a request shape no user ever produces, and stays green through this exact bug. Use node:https (or undici.request, or a real browser), which write headers verbatim.

Routing Traps

The gating trap and the SSR-index outage above share one root cause: the asset layer's routing decision -- serve the file, or hand off to the Worker -- happens before your Worker code runs, and where that boundary sits depends on the request itself and on your Wrangler version. A few more traps fall out of the same mechanism.

Browser Navigation Can Bypass a Gate That curl Passes

If a Worker script (main) is configured, not_found_handling is set, and the compatibility date is 2025-04-01 or later (or the assets_navigation_prefers_asset_serving compatibility flag is set), a navigation request that matches no static asset is answered directly by not_found_handling and never reaches the Worker -- regardless of run_worker_first's default false. Non-navigation requests (no Sec-Fetch-Mode: navigate header -- curl, most server-to-server calls) still fall through to the Worker as before.

If you were counting on "no asset matched -> falls through to the Worker" as an implicit backstop -- a catch-all authorization check on any path that isn't a known public file, say -- that backstop silently stops running for real browser traffic the moment the project sits on a current compatibility date. A curl check of the "protected" path still reaches the Worker and reports success; every actual browser visitor gets the not_found_handling response instead, with no gate at all.

curl passes, every browser bypasses -- this is a security gate silently left open

A manual curl check proves nothing about what a browser gets once your compatibility date crosses 2025-04-01. Verify with a request that carries Sec-Fetch-Mode: navigate (see the SSR-index verification commands above), or remove the ambiguity entirely: set run_worker_first = true so the Worker runs regardless of request shape.

The Complete Gating Fix Pairs run_worker_first = true With a Worker That Serves Assets Itself

run_worker_first = true only changes routing -- it makes every request reach the Worker first. It does not gate anything by itself, and it does not serve assets by itself. The Worker has to check authorization and then serve the matching asset itself via the binding:

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const authorized = await checkAuth(request, env);
    if (!authorized) {
      return new Response("Unauthorized", { status: 401 });
    }

    // Gate passed -- now serve the matching static asset ourselves.
    return env.ASSETS.fetch(request);
  },
};

Flip the flag without adding this and the fix is incomplete: the Worker now runs for every request, but unless it explicitly authorizes and then calls env.ASSETS.fetch(), it has no way to actually return the site's static files. The fix is the pair -- run_worker_first = true and an authenticated env.ASSETS.fetch(request) call -- not the flag alone.

Array-Form run_worker_first Is a Cost Optimization, Not a Gate

Wrangler >= 4.20.0 accepts an array of route patterns for run_worker_first instead of a boolean:

[assets]
directory = "./dist"
binding = "ASSETS"
not_found_handling = "single-page-application"
# Only these paths invoke the Worker first; every path NOT listed here --
# including paths you haven't thought of yet -- serves straight from the
# asset layer with no Worker involvement at all.
run_worker_first = ["/oauth/callback", "/api/*"]

This exists to cut billable Worker invocations for client-heavy SPAs -- most navigations serve index.html straight from the edge with zero Worker cost -- and it can double as a gate too, but only for a bounded subtree where everything outside the list is deliberately public. run_worker_first = ["/admin/*"] on a site where /admin/* is the only thing that needs auth and everything else is meant to be public is a legitimate security gate: it protects the admin subtree and avoids invoking the Worker for the public assets that don't need it.

Where the array form goes wrong is a catch-all or default-deny requirement -- "authorize every request" or "nothing is public unless explicitly listed." There, every path outside the array has no gate whatsoever, and there is no implicit deny for paths you didn't think to list; a route added later ships ungated by default. Use run_worker_first = true (or gate inside the Worker itself, as above) when the requirement is default-deny. Reserve the array form for a known, bounded subtree -- whether that's for cost optimization, or as a gate over a specific set of routes with everything else intentionally public.

not_found_handling = "single-page-application" Always Serves the Root index.html

For any unmatched path, the SPA fallback serves the contents of the top-level dist/index.html with a 200 OK -- no matter how deep the path is. There is no per-directory fallback.

[assets]
directory = "./dist"
binding = "ASSETS"
# Every unmatched path -- /app-a/settings, /app-b/reports, /anything --
# gets the SAME top-level dist/index.html. There is no per-directory variant.
not_found_handling = "single-page-application"

The trap surfaces on multi-zone builds: if dist/app-a/index.html and dist/app-b/index.html ship as separate SPA shells, each expected to own its own subtree, the fallback ignores that structure entirely. A request to /app-a/settings that matches no real file gets the root dist/index.html, not dist/app-a/index.html -- unless the root shell's own router is what's supposed to own /app-a/* in the first place.

run_worker_first and _redirects See the Request Before auto-trailing-slash Rewrites It

The default html_handling = "auto-trailing-slash" serves foo/index.html with a trailing slash, and redirects a bare /foo request to /foo/:

GET /foo   -> 307 Location: /foo/
GET /foo/  -> 200 (serves dist/foo/index.html)

That redirect is the asset layer's own behavior, and it only fires once a request has already been routed there. run_worker_first pattern matching happens upstream of it, against the pathname as originally requested -- so a pattern written against the bare form (/admin, no trailing slash) does see a request for /admin; it isn't skipped. The same is true of _redirects rules, which also evaluate before html_handling.

What the bare form alone misses is everything past it: the canonical /admin/ destination the browser lands on after the redirect, and anything nested under it, like /admin/settings. A /admin/* entry doesn't cover the bare /admin request either -- the glob requires the literal / that follows. Cover both explicitly when the whole subtree needs the Worker:

[assets]
directory = "./dist"
binding = "ASSETS"
not_found_handling = "single-page-application"
# "/admin" matches the request as originally made, before auto-trailing-slash
# would redirect it. "/admin/*" covers the "/admin/" destination and every
# path nested under it. Neither pattern covers the other -- list both.
run_worker_first = ["/admin", "/admin/*"]

env.ASSETS.fetch() Matches by Pathname Only

env.ASSETS.fetch(request) -- the call the gate above uses to serve an asset after authorizing the request -- matches only the URL pathname. Query strings play no part in asset matching:

// Both requests resolve to the SAME asset -- the query string is ignored
// for matching purposes, even though it's still visible to your Worker code.
await env.ASSETS.fetch(new Request("https://example.com/report.pdf"));
await env.ASSETS.fetch(new Request("https://example.com/report.pdf?v=2"));

Do not rely on a query string to select between asset variants through the binding -- it always resolves to the same file. Versioning for hashed bundles belongs in the filename itself; see Browser Caching for Hashed Assets for the _headers pattern that pairs with content-hashed filenames.

Summary

FieldDefaultSet it when
binding--Worker (or its adapter) actually calls env.ASSETS.fetch() -> set it; Worker has main but only relies on asset-first routing and never touches env.ASSETS -> omit it; assets-only Worker (no main) -> omit it, wrangler hard-errors if it's set
not_found_handling"none"SSG -> "404-page"; SPA -> "single-page-application"
run_worker_firstfalseThe Worker must gate/authorize every request (default-deny) -> true; a subset of routes needs Worker-first for cost reasons, or as a gate over a bounded subtree with everything else intentionally public -> an array of patterns; or the index is SSR with a dist/404.html present -> ["/", ...]
html_handling"auto-trailing-slash"Gating a directory subtree with run_worker_first -> list both the bare form and the /dir/* form; each covers requests the other doesn't
workers_devtrueStop serving production on *.workers.dev -> false
preview_urlsmatches workers_devAlways set explicitly so previews survive workers_dev = false

Revision History

CreatedUpdated