Notes from building this site. Everything below either broke something or would have. Marked hit it where I actually tripped over it, known where it's carried over from a previous project and I didn't re-trigger it here.
Set compatibility_date = "2026-08-16" in wrangler.toml and wrangler dev died instantly:
service core:user:stevex-us: This Worker requires compatibility date "2026-08-16",
but the newest date supported by this server binary is "2026-06-10".
MiniflareCoreError [ERR_RUNTIME_FAILURE]
The date is checked against the workerd binary bundled with your installed wrangler, not
against what Cloudflare's edge supports. So:
wrangler dev fails. wrangler deploy succeeds. The two use different runtimes.Pin it to something the local runtime actually has, and only raise it when you upgrade wrangler. Currently 2026-06-10 for wrangler 4.98.0.
With custom_domain = true under [[routes]], a deploy reconciles the domain list. Any custom
domain attached through the Cloudflare dashboard gets detached by that reconcile.
[[routes]]
pattern = "stevex.us"
custom_domain = true
Pick one mechanism per worker and stay there. (Observed live during the stevexsports.com apex cutover, which is why this repo is 100% declarative.)
workers_dev = true is load-bearing once you add routes — knownAdding [[routes]] implicitly disables the *.workers.dev URL unless workers_dev = true is
still present. You lose the fallback URL — handy when you're mid-DNS-change — without any
warning that you did.
Deploy reported success and bound www.stevex.us, but:
curl: (6) Could not resolve host: www.stevex.us
Cloudflare creates the DNS record as part of provisioning, and it isn't instant — about 30
seconds here. The apex resolved immediately; only the new www record lagged. Wait a minute
before debugging a custom domain that 404s right after its first deploy.
env.KV.list() returns key names and metadata only. Values need a get() each:
const { keys } = await env.QUOTES.list({ prefix: 'q:', limit: 500 });
const rows = await Promise.all(keys.map((k) => env.QUOTES.get(k.name, 'json')));
Parallelize with Promise.all or you serialize a round trip per key. If a list is going to get
big, this is the moment to stop using KV as a database and reach for D1.
Related: one key per record, not one blob holding an array. A delete then touches one key instead of rewriting the whole list, and two concurrent writes can't clobber each other.
Pages here are served cache-control: public, max-age=300, which is right for content baked in
at build time and wrong the moment a page reads live data — a newly published record stays
invisible for up to five minutes and looks like a broken write.
const cache = page.app ? 'no-store' : 'public, max-age=300';
The write endpoint checks a secret that may not exist yet — you deploy the code before you run
wrangler secret put. The dangerous shape is the one that only enforces when configured:
// DON'T: no secret set => the condition is never true => every write is allowed
if (env.QUOTES_TOKEN && given !== env.QUOTES_TOKEN) return unauthorized();
That reads as "check the token" and behaves as "check the token, unless there isn't one," so a missing secret turns into an open endpoint. Invert it — absence of config is itself a refusal:
if (!env.QUOTES_TOKEN) return { ok: false, status: 503 }; // fail closed
if (given !== env.QUOTES_TOKEN) return { ok: false, status: 401 };
The window between "deploy" and "set the secret" is exactly when the endpoint is public and you're least likely to be watching.
Then verify it from the outside. Before the secret was set, production returned:
POST /api/quotes -> 503 {"error":"QUOTES_TOKEN is not configured; writes are disabled."}
and after, a wrong guess returned 401. The 503→401 flip is the proof the gate is real — and
it's checkable without ever knowing the secret.
::before escapes its box — hit itDecorative quote marks are position: absolute inside the quote figure. Lifting one to sit
above the first line looked fine in isolation:
transform: translateY(-0.55em); /* 3.4rem font -> ~30px above the figure */
Absolute positioning takes it out of flow, so nothing reserves that space — the glyph rendered on top of the heading above it. Fix is top padding on the container to lift into, or don't lift at all. If a pseudo-element is positioned outside its parent's box, something else has to make room for it.
Content is compiled into a JS module at build time. Every string goes out through
JSON.stringify, so markdown can contain $, backticks and backslashes with zero ceremony.
The alternative — interpolating text into a template literal — means every author has to
remember to escape \$ forever, and one miss puts a stray backslash on the live page. One
boundary that's correct beats N call sites that have to be.
Separately and non-negotiably: anything a user submits gets escaped on render, every time. Test it with a real payload rather than assuming —
<img src=x onerror=alert(1)> -> <img src=x onerror=alert(1)>
A form refused to submit under browser automation. It wasn't the page: synthetic mouse events
at the right coordinates don't always run a submit button's activation behaviour. Calling
button.click() — which does — submitted correctly, as did a human.
Verify the handler, not the click. Otherwise you'll go hunting for a bug that only exists inside the test harness.