How to Use wrangler - Local Development and Deployment for Cloudflare Workers
Summary: Installing wrangler and setting up types, booting workerd with wrangler dev, and deploying. Starting with the fact that wrangler dev is not a frontend dev server.

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 devactually does, and how it differs fromvite 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 build → dist/), 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
fsandprocessnor the browser’sdocument. 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 caches — names 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 | |
|---|---|
include | Which folder these rules apply to |
lib | What to assume exists in the standard environment ("DOM" → window, document) |
types | Additional global packages (@cloudflare/workers-types → HTMLRewriter, 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 dev | wrangler dev | |
|---|---|---|
| What it is | frontend **dev server | a replica of the production runtime** |
| Input | src/ source | build output (dist/) |
| Source changes | applied instantly via HMR | not applied — you have to rebuild |
| Worker | doesn’t exist | runs |
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-typesandwrangler. - You can use
wranglerwithout installing it, vianpx 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
documentin a Worker passes, only breaking after deployment. wrangler devis a local replica of the production runtime, not a frontend dev server. You have to builddist/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.