Skip to content
Metric VaultHelp Center
Open app

Local development

How to run Metric Vault on your machine with wrangler pages dev, the dist/ copy trap that wastes more time than anything else here, and what cannot be tested locally.

Last updated 2026-09-14

Summary#

There is nothing to install and nothing to build. Clone the repository, put a .dev.vars file next to wrangler.toml, copy the files you care about into dist/, and run wrangler pages dev dist. The site comes up on http://127.0.0.1:8788 with the real worker in front of it.

The one thing that will cost you an afternoon if nobody tells you: the dev server serves dist/, not the repository root. Read the next section before you write a line of code.

The dist/ copy trap#

Warning

Warning: wrangler pages dev dist serves the dist/ copy of the site, not the files you are editing. After you change dashboard.html, _worker.js, admin.html, index.html or any other top-level file, you must copy it into dist/ or your change will not appear locally. This wastes more developer time in this repository than anything else.

Why it works this way: the project is a Cloudflare Pages project in advanced mode, and wrangler.toml declares pages_build_output_dir = "dist". Wrangler therefore treats dist/ as the published folder both in CI and locally. dist/ is git-ignored and rebuilt from scratch by the deploy workflow, so it is not a mirror of anything: locally it holds whatever you last put there.

The symptom is always the same and never looks like a stale file. You edit, reload, and see the old behavior. You add a console.log, reload, and it does not appear. Nothing errors. You start doubting the edit.

Two habits fix it permanently:

  1. Copy on every save. Keep a one-line command in your shell history and run it before each reload.
  2. When something is inexplicable, check the timestamp of the file in dist/ first, before you debug the code.

Be aware that your local dist/ can hold files CI would never ship, because nothing prunes it between runs. compare.html and index-legacy.html are the usual leftovers: neither exists at the repository root any more, but a dist/ built before they were removed still has them. Do not treat local dist/ as evidence of what is in production.

Purpose#

Local development exists to let you iterate on the worker and the app without pushing to main, because a push to main goes live immediately. Everything that can be verified locally should be, and the small set of things that cannot be is listed below so you do not spend an hour trying.

Requirements#

RequirementDetail
Node.jsNode 24 is what the verification scripts in tests/ are run with. The deploy workflow itself uses Node 22, but it runs no scripts
WranglerInvoked through npx. There is no lockfile and no local install to maintain
.dev.varsAt the repository root. See Environment variables and secrets for every name and what breaks without it
A Cloudflare loginOnly if the [ai] Workers AI binding stays enabled. Workers AI has no local simulator, so wrangler pages dev needs wrangler login
Nothing elseNo package.json, no npm install, no bundler, no node_modules

Permissions#

You need write access to the repository and, for anything that touches real provider data, valid API credentials in .dev.vars. You do not need access to the Cloudflare Pages project to develop locally. You do need it to change production configuration, and you need repository secrets access to change how deploys authenticate. Those are covered in Deployment.

text
repository root
├── wrangler.toml     pages_build_output_dir = "dist"
├── .dev.vars         local secrets, git-ignored
└── dist/             what the dev server actually serves

Local base URL: http://127.0.0.1:8788.

Step-by-Step Guide#

  1. Clone the repository and change into it. There is no install step.
  1. Create .dev.vars at the root. One NAME=value per line, no quotes required. Start with the providers you need for the feature you are building; the worker degrades rather than crashing when a key is absent, so a partial file is fine.

`` DATAFORSEO_LOGIN=... DATAFORSEO_PASSWORD=... OPENAI_API_KEY=... ANTHROPIC_API_KEY=... MV_INTERNAL_SECRET=any-local-value ``

> Warning: .dev.vars is git-ignored through the glob .dev.vars*. > Never rename a copy to something outside that glob. A .dev.vars.bak-crlf > backup once got committed with every live key in it.

  1. Stage the site into dist/. The simplest correct approach is to run the same copy the deploy workflow runs: create dist/, copy every top-level *.html, copy the named root files (_worker.js, _headers, _routes.json, sw.js, manifest.json, chat-widget.js, pwa-install.js, blog-embed.js, chatbot.js, robots.txt, sitemap.xml, the icons and logos), and copy the directories icons free-tools reports legal js css images recursively.
  1. Start the dev server.

``bash npx wrangler pages dev dist ``

Wrangler reads wrangler.toml, applies the D1, R2 and Workers AI bindings, and serves on http://127.0.0.1:8788. Local D1, KV and R2 state lives under .wrangler/state/v3/, which is git-ignored.

  1. Edit a file, then copy it into dist/ again, then reload. This is the loop. Editing without copying changes nothing you can see.
  1. If you edited login.html, re-sync the inlined copy.

``bash node tests/login-inline-sync.mjs --write ``

_worker.js intercepts GET /login and /login.html and returns an inlined const LOGIN_HTML template literal instead of the static asset. It was added to dodge a Pages caching bug that served stale or zero-byte HTML. Nothing regenerates that copy automatically, so editing login.html alone has no effect anywhere. Commit both files. The no-argument form is a drift check that exits 1.

  1. Run the gates before you commit. At minimum node tests/design-lint.mjs for UI changes and node tests/chkblk.mjs dashboard.html after editing any inline script. The full list is in Verification scripts.

Driving background jobs locally#

Cloudflare Pages never calls scheduled(), so no job will fire on a timer on your machine either. Trigger the whole set by hand:

bash
curl -X POST http://127.0.0.1:8788/api/cron/run \
  -H "x-mv-internal-secret: $MV_INTERNAL_SECRET"

Add ?job=monitor (or schedules, rank_alerts, tier2, crawl, sweep_orphans, purge_results, purge_watchtower, sweep_caches, higgsfield, social_schedule, blog_schedule, cms_schedule, billing_sync, google_sync, ga4_sync, gsc_inspect, gsc_vitals, backlink_watch) to run one. An unknown value returns 400 with the valid list. The jobs self-throttle in the database, so calling this repeatedly is safe. See Background jobs and scheduling.

What cannot be tested locally#

ThingWhy
Workers AI (env.AI): /api/translateWorkers AI only runs remotely. There is no local simulator, and wrangler pages dev requires wrangler login while [ai] is enabled. Comment the binding out for fully offline work
Anything needing a key absent from .dev.vars: Stripe, Resend, the Supabase service key, Pexels, DeepL, Google OAuthThose names are deliberately not in the local file
Cron timingPages never fires scheduled(). Drive it with the explicit POST above
/free-tools/*, /ext-preview.html, /legal, the logo PNGsThose routes fetch their content from raw.githubusercontent.com at request time, so they serve committed main content, not your working tree. Editing those files locally changes nothing until the change is pushed
Stripe webhooksThe endpoint fails closed without STRIPE_WEBHOOK_SECRET, and a real signature cannot be forged

R2 is the exception in the other direction: wrangler pages dev simulates the BLOG_MEDIA bucket locally, so blog media work needs no remote credentials.

Line endings#

dashboard.html and legal.html use CRLF. A search-and-replace written for \n matches nothing in them; write \r?\n. login.html is also CRLF, but ECMAScript normalises CRLF to LF inside a template literal, so the inlined copy is LF and login-inline-sync.mjs compares line-ending agnostically. Several public pages have mixed endings, which is why site-chrome-sync.mjs preserves untouched regions byte for byte.

Troubleshooting#

SymptomLikely causeFix
An edit has no visible effectThe file was not copied into dist/Copy it, then hard-reload. Check the timestamp in dist/
An edit to login.html has no effect/login is served from the inlined LOGIN_HTML in _worker.jsnode tests/login-inline-sync.mjs --write, then copy _worker.js into dist/
A navbar or footer edit is revertedThe chrome is stamped from partials/Edit partials/site-nav.html or partials/site-footer.html, then node tests/site-chrome-sync.mjs --write
MONITOR_DB not bound. Wrangler may need a redeploy.The bindings were not appliedYou passed a positional directory to wrangler, or wrangler.toml is not being read. Run wrangler pages dev dist from the repository root
wrangler pages dev asks you to log inThe [ai] binding is enabled and Workers AI is remote-onlyRun wrangler login, or comment out the [ai] block for offline work
/api/cron/run returns 503MV_INTERNAL_SECRET is not in .dev.varsAdd any value locally. The check fails closed on purpose
/api/diag/* returns 403Same secret, or the ?key= value does not matchAppend ?key=<MV_INTERNAL_SECRET>
A free-tools page shows old contentThose pages are fetched from GitHub raw at request timePush the change, or test the underlying /api/tools call directly
A search-and-replace matches nothing in dashboard.htmlCRLF line endingsUse \r?\n

FAQs#

Do I need to run npm install? No. There is no package.json anywhere in the repository. Wrangler is invoked through npx, and the scripts in tests/ use only Node's standard library.

Can I just point the dev server at the repository root instead of dist/? Wrangler reads pages_build_output_dir from wrangler.toml, and the deploy path depends on that value being dist. Changing it to work around the copy step would change what CI publishes. Copy into dist/ instead.

Why does CI never hit this problem? Because the deploy workflow builds dist/ from scratch on every run. The trap is purely local: your dist/ persists between runs and drifts from your working tree.

How do I test as a signed-in user? Sign in normally against Supabase. tests/login-verify.cjs automates it with Playwright, taking MV_EMAIL and MV_PW from the environment only, and writes a screenshot. It expects the dev server on http://127.0.0.1:8788.

Is local D1 the same as production D1? No. Local D1 lives in .wrangler/state/v3/d1 and starts empty. Tables are created lazily by the worker on first use, so most features build their own schema the first time you exercise them. See Schema migrations.

How do I check my local copy matches what would ship? Compare the staging step in .github/workflows/deploy.yml against your dist/. Anything in dist/ that the workflow does not stage will never reach production, and anything it stages that you did not copy will behave differently locally.

See also

Was this article helpful?