Cloudflare's Workers Assets binding lets you ship a static site (HTML, CSS, JS, images) alongside Worker code as a single deployable. By default, requests for static files bypass the Worker entirely — fast, but it means you can't add security headers, redirect on certain paths, or do SPA-style routing. The fix is one config line: run_worker_first = true. This guide walks through the setup, the routing logic, and the gotchas.
- A Cloudflare account with a domain added as a zone.
npm install -g wrangler(or usenpx wrangler).- A static site (a folder of HTML/CSS/JS files) to serve.
- Node 18+ for the Wrangler CLI.
Step 1Project layout
Keep the static site and the Worker as siblings. The site folder is the source of truth for the frontend; the Worker reads from it.
my-project/ ├── site/ # static HTML, CSS, JS, images │ ├── index.html │ └── ... └── worker/ ├── src/index.js # the Worker ├── package.json └── wrangler.toml
Step 2The wrangler.toml that matters
Three things make this pattern work: the [assets] block, not_found_handling, and the headline — run_worker_first.
name = "my-project" main = "src/index.js" compatibility_date = "2025-04-01" routes = [ { pattern = "example.com", custom_domain = true }, ] [assets] directory = "../site" binding = "ASSETS" # Unknown paths serve site/404.html (with 404 status) not_found_handling = "404-page" # THIS is the load-bearing line — see Step 4 run_worker_first = true
Without run_worker_first, the Assets binding intercepts requests before your Worker code runs for any path that matches a static file. With it on, the Worker runs on every request — and you call env.ASSETS.fetch() yourself when you want the static asset.
Step 3The Worker
A minimal handler that wraps every HTML response with security headers, routes /api/* to your API code, and falls through to ASSETS for everything else.
const SPA_PATHS = new Set([ '/products', '/work', '/log', '/dashboard', ]); export default { async fetch(request, env, ctx) { const url = new URL(request.url); // API routes — handle in the Worker if (url.pathname.startsWith('/api/')) { return handleApi(request, env, url); } // SPA paths — rewrite to /app so the React shell handles routing if (SPA_PATHS.has(url.pathname) || url.pathname.startsWith('/products/')) { const rewritten = new Request(new URL('/app', url), request); return wrapHtml(await env.ASSETS.fetch(rewritten)); } // Everything else: static asset return wrapHtml(await env.ASSETS.fetch(request)); } }; function wrapHtml(res) { const ct = res.headers.get('content-type') || ''; if (!ct.includes('text/html')) return res; const h = new Headers(res.headers); h.set('X-Frame-Options', 'DENY'); h.set('X-Content-Type-Options', 'nosniff'); h.set('Referrer-Policy', 'strict-origin-when-cross-origin'); h.set('Content-Security-Policy', "frame-ancestors 'none'"); return new Response(res.body, { status: res.status, headers: h }); }
Step 4Why run_worker_first is the whole game
This is the part that took me longest to internalize. Without it, the platform handles routing in a way that looks like it works but quietly bypasses your Worker for static files. The HTML gets served, but the headers you set in the Worker are nowhere — because the Worker never ran for that request.
With run_worker_first = true, the contract becomes simple: the Worker runs on every request, you decide what each path does. Want security headers on every HTML response? Wrap it. Want a 301 redirect on /old-path? Return a redirect. Want to bypass to the static asset? Call env.ASSETS.fetch(request).
After deploy, curl -sI https://your-domain/ should show X-Frame-Options: DENY and friends on the HTML response. If they're absent, run_worker_first isn't on — the Worker didn't run. (Easy mistake: setting it in a non-active environment, or wrangler config that doesn't reach the deploy.)
Step 5SPA routing without losing SEO
If part of your site is a React SPA mounted at /app, you want URLs like /products and /dashboard to look like real pages but be served by the SPA shell. The trick: explicitly list the SPA-owned paths and rewrite (not redirect) to /app. The URL bar stays on the requested path; the client-side router takes over.
Listing the paths explicitly (rather than "anything that isn't a real file") avoids the trap where a missing image silently lands you in the SPA. Real 404s should look like real 404s.
Step 6Deploy
cd worker npx wrangler deploy
The first deploy provisions DNS and SSL for the custom domain in ~30 seconds. Subsequent deploys propagate globally in 5–10s.
- Cache 404s after deploy. Cloudflare will briefly serve cached 404 responses for newly added paths. Add a cache-busting
?v=query for the first verification, or wait 5–10s. - Path normalization.
/pageand/page/aren't the same; the Assets binding will 404 on the form that doesn't have a file. Pick one shape and stick to it. - Static-site directory in
directoryis relative towrangler.toml's location, not your CWD. If you deploy from a different dir than expected, this silently breaks. - SPA_PATHS shadowing. If a SPA path collides with a real static file path, the static file wins (or loses, depending on order). Pick non-overlapping names — e.g. don't use
/aboutfor both the SPA and anabout.html. - Edge cache + worker-set headers. If you set Cache-Control on the static response, the next request might hit the edge cache and skip your Worker entirely (and lose the security headers). For HTML, set
Cache-Control: no-storeor accept the trade-off.