Cover image showing the Worker runtime you ran locally going up to the edge platform as is

Overview

This post is part of the Cloudflare Workers Static Site Guide - From Deployment to SEO series.

Working with Cloudflare Workers is effectively working with wrangler. Running locally and deploying both go through the same CLI.

But the name creates one misunderstanding. wrangler dev is not a frontend dev server. I got stuck on this for quite a while myself at first.

What this post covers

  • What you need to install — and the fact that your runtime dependencies don’t grow
  • Why you need to separate the type configuration for your Worker code
  • What wrangler dev actually does, and how it differs from vite dev
  • Why the static site build has to come first
  • How to verify that the Worker actually modified the response
  • Deployment — injecting per-environment values, and what to watch for in CI

What it doesn’t cover

The structure and request flow of Workers Static Assets are in Cloudflare Workers Static Site Hosting - Request Flow and Billing.

Prerequisites

Beyond having a Cloudflare account and being able to build a static site (npm run builddist/), no prior knowledge is required.


Installing wrangler

It’s Cloudflare’s CLI. It handles both local execution and deployment. The list of commands is in the Commands documentation.

The option of not installing it

wrangler is a deployment tool, not application code. So you can leave it out of your dependencies and pull it down only when you need it.

1# As a devDependency
2npm i -D wrangler
3npx wrangler dev
4
5# Or without installing - pin the major version
6npx wrangler@4 dev
7pnpm dlx wrangler@4 dev

The advantage of the latter is that local and CI use the same version, and the deployment tool never enters your app’s package.json. The downside is the small download time each run.

Either way, your runtime dependencies don’t grow. Cloudflare is what executes the Worker.

Types are a separate package

Workers globals like Fetcher, HTMLRewriter, ExecutionContext, and caches come from the types package.

1npm i -D @cloudflare/workers-types

Why you need to separate the type configuration

The problem comes from having two execution environments inside one project.

1src/                  runs in the browser   has document · no HTMLRewriter
2cloudflare/workers/   runs on workerd       no document · has HTMLRewriter

The globals available are the opposite of each other, and TypeScript can’t tell which is which just by looking at the file. It only knows what you wrote in tsconfig. So you have to tell it “which folder is which environment,” and to do that you need two configurations.

For reference, Workers is not Node. It’s a third runtime with neither Node APIs like fs and process nor the browser’s document. So you can’t just reuse an existing Node configuration either.

If there’s only one configuration, it’s usually browser-based. Check Worker code with that configuration and the code below passes compilation.

1export default {
2  async fetch(request: Request): Promise<Response> {
3    // There is no document in a Worker. This still type-checks.
4    const el = document.getElementById('root');
5    return new Response(el?.textContent ?? '');
6  },
7};

Deploy it and it dies with document is not defined. You’ve deferred to runtime something the type checker should have caught.

There’s a reverse direction too. Without @cloudflare/workers-types, HTMLRewriter, ExecutionContext, and caches all become “cannot find name.”

Cramming both into one configuration isn’t the answer either. Put DOM and Workers types together and things like Request, Response, and cachesnames that exist on both sides in different shapes—get mixed up, and you end up with the wrong type.

So you split the configuration and tie it together with project references. Each configuration decides three things.

What it decides
includeWhich folder these rules apply to
libWhat to assume exists in the standard environment ("DOM"window, document)
typesAdditional global packages (@cloudflare/workers-typesHTMLRewriter, caches)
1// tsconfig.worker.json
2{
3  "extends": "./tsconfig.node.json",
4  "compilerOptions": {
5    // No DOM. Workers globals instead.
6    "types": ["@cloudflare/workers-types"]
7  },
8  "include": ["src/worker"]
9}
1// tsconfig.json
2{
3  "files": [],
4  "references": [
5    { "path": "./tsconfig.app.json" },
6    { "path": "./tsconfig.worker.json" }
7  ]
8}

Running locally — the most confusing part

wrangler dev is not a frontend dev server

The dev in the name makes it easy to mistake for something like vite dev, but what it does is entirely different.

wrangler dev boots the exact same runtime as the Cloudflare edge (workerd) locally, as a web server. It runs, on your machine, the very program that will run in a Cloudflare data center once deployed. HTMLRewriter, caches, and env.ASSETS are all the same implementations as in production.

In other words, it’s “standing up a miniature of production locally” — not a tool that watches your source and transforms it.

vite devwrangler dev
What it isfrontend **dev servera replica of the production runtime**
Inputsrc/ sourcebuild output (dist/)
Source changesapplied instantly via HMRnot applied — you have to rebuild
Workerdoesn’t existruns

That’s why the static site build comes first

env.ASSETS points at “the pile of deployed static assets.” Without that pile, the Worker has nothing to pull from.

--assets is what tells it where to read that pile locally.

1npx wrangler@4 dev --assets dist
2#                            ^^^^ build output, not src/

The order is always this.

1# 1. Build the static site first -> dist/
2npm run build
3
4# 2. Boot workerd with dist/ as its asset store
5npx wrangler@4 dev --assets dist
1⛅️ wrangler 4.x
2Ready on http://localhost:8787

If you changed UI code, you have to start again from step 1. wrangler dev only looks at dist/, so it has no idea you edited src/. Most instances of “I definitely changed it, so why is it the same?” are this.

Wrapping it in a script makes life easier.

1// package.json
2{
3  "scripts": {
4    // cf- prefix: plain "worker" collides with Web Worker / Service Worker / worker_threads
5    "preview:cf-worker": "npm run build && wrangler dev --assets dist"
6  }
7}

You can pin the port in the configuration.

1// wrangler.jsonc
2{ "dev": { "port": 6173 } }

Workflow

You don’t keep both servers running at once.

  • While building the UI, use vite dev. The Worker doesn’t run.
  • Only when you’ve touched the Worker, build and then verify with wrangler dev.

Verify against the “raw response”

If your code modifies the response, you must not look at the rendered screen. The screen is what JS produced, so the Worker’s work and the browser’s work are mixed together. Even with the Worker bypassed entirely, the screen looks just as fine.

View source in the browser

1view-source:http://localhost:6173/products/1234

The Elements panel in DevTools is no use here — that’s the current DOM, which is already after JS has run.

The DevTools Network tab — pick the document request and look at Response, and you get the body exactly as received. You can see the response headers too, which makes it the most accurate option.

curl — handy when you want to extract just one thing or run it from a script.

1curl -s http://localhost:6173/products/1234 | head -20
2curl -s -D - -o /dev/null http://localhost:6173/products/1234   # headers only

If the value didn’t change, the Worker didn’t run. Check first whether that path is in run_worker_first.


Deployment

Once it checks out locally, ship it with the same CLI.

1npx wrangler@4 deploy --assets dist

Values that change per environment

Values that vary by environment — site URL, API URL, keys — are better passed via the CLI than hard-coded into the configuration file (environment variables documentation). Write them into the file and they fork per environment, and when they drift apart things silently misbehave.

1npx wrangler@4 deploy --assets dist \
2  --name "prod-my-site" \
3  --var SITE_URL:"https://example.com" \
4  --var API_BASE_URL:"https://api.example.com"

CLI values beat the configuration file, so you never have to write the same value in two places.

1export interface Env {
2  ASSETS: Fetcher;
3  SITE_URL: string;
4  API_BASE_URL: string;
5}

Leaving a deployment trail

--tag and --message are labels attached to a Worker version. They let the dashboard answer “which commit is currently live?”

1npx wrangler@4 deploy --assets dist \
2  --tag "$GIT_SHA" \
3  --message "ref: $GIT_BRANCH"

Without them you’re matching things up by eye when it’s time to roll back.

Running in CI

wrangler asks about various things at the end of a deploy (telemetry consent and so on). In CI there’s nobody to answer, so it stops right there.

1CI=true WRANGLER_SEND_METRICS=false npx wrangler@4 deploy --assets dist

Authentication goes through environment variables. wrangler reads these names directly, so there’s no need to export them separately.

1CLOUDFLARE_API_TOKEN
2CLOUDFLARE_ACCOUNT_ID

Don’t deploy to a Worker that doesn’t exist

wrangler deploy --name X creates X if it doesn’t exist, and overwrites it if it does. Convenient as that looks, a deploy with the wrong name silently “succeeds.” A stray Worker gets created while the site you’re actually looking at doesn’t change.

In CI it’s safer to check existence once before deploying. Query the API for that name and look at the status code only.

 1# -o /dev/null  discard the body - we only want the status
 2# -w            print just the status code
 3code=$(curl -s -o /dev/null -w '%{http_code}' \
 4  -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
 5  "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/workers/scripts/$NAME")
 6
 7case "$code" in
 8  2[0-9][0-9]) ;;                                  # exists - go ahead
 9  404) echo "no such Worker: $NAME" >&2; exit 1 ;; # typo, or first-ever deploy
10  401|403) echo "token lacks Workers permission" >&2; exit 1 ;;
11  *) echo "check failed (HTTP $code)" >&2; exit 1 ;;
12esac

You have to accept the whole 2xx range. This endpoint returns the script body, and a Worker containing only static assets has an empty body, so you get a 204. Check for 200 alone and every deploy after the first gets blocked.

And you must separate “doesn’t exist” (404) from “not authorized” (401/403). Lump them together and, in a situation where the token simply lacks permission, you’ll emit the wrong guidance — “it’s the first time, so allow creation” — and following that advice gets the deploy killed at authentication again.


Summary

  • Runtime dependencies don’t grow. Cloudflare executes the Worker. For development you only need @cloudflare/workers-types and wrangler.
  • You can use wrangler without installing it, via npx wrangler@4. Pin the version and local and CI use the same one.
  • Separate the type configuration for your Worker code. Check everything with one tsconfig and using document in a Worker passes, only breaking after deployment.
  • wrangler dev is a local replica of the production runtime, not a frontend dev server. You have to build dist/ first, and if you changed UI code you have to rebuild.
  • Verify whether the Worker modified the response against the raw response. The rendered screen and the DevTools Elements panel can’t tell the difference.
  • Pass per-environment values via the CLI --var. Hard-code them in the configuration file and the same value ends up in two places, forking per environment.
  • Deploying to a Worker that doesn’t exist silently succeeds. In CI, check existence once.

References