Weather.Co is now free for every pilot — always free for CFIs and flight schools.
Lab · Guides · Cloudflare Workers + Assets binding
Lab guide · Cloudflare

Cloudflare Workers + Assets binding.

The pattern behind kuhlman-co.com — a single Worker that serves both API routes and a static site, with security headers wrapped around every response. How run_worker_first changes everything, and the SPA-routing trick.

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.

Before you start
  • A Cloudflare account with a domain added as a zone.
  • npm install -g wrangler (or use npx 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).

Tip — verify in production

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.

Common gotchas
  • 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. /page and /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 directory is relative to wrangler.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 /about for both the SPA and an about.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-store or accept the trade-off.

Why this matters

The default Cloudflare Pages or Workers Sites flow has you choose between "fast static" and "smart Worker." The Assets binding with run_worker_first ends that choice. You get edge-served static files and Worker logic on the response. For a studio site like this one, that means: HTML served from KV-backed edge storage, security headers wrapped on every response, API endpoints (METAR proxy, sign-in, etc.) on the same domain, no CORS needed.

The same pattern is in production for kuhlman-co.com. The whole config is ~120 lines of index.js and ~30 lines of wrangler.toml. No build step on the static side.

Sources: Cloudflare Workers docs — Static Assets, Assets binding; kuhlman-co/worker/src/index.js. Cloudflare's Assets binding requires Wrangler 3.91+ and is GA as of mid-2025.