Project eight, a navigation site
Render a link directory with one Worker, store links in KV, cache the page with the Cache API, and purge that cache after authenticated writes.
Edited and verified by Orange Book Editorial Team ·
Completion criteria
The home page reads link data from KV and renders a categorized directory; the page is cached through the Cache API so the second of two consecutive visits is a cache hit; authenticated POST/DELETE requests write KV and purge the cache immediately, so the next visit renders fresh data; invalid slugs and non-http(s) URLs are rejected with 400.
Prerequisites and companion example
You need Node.js 22, pnpm, and the repository dependencies. The example lives in examples/nav-site, and Wrangler emulates KV locally. Copy the local secret, then start the dev server:
cp examples/nav-site/.dev.vars.example examples/nav-site/.dev.vars
pnpm wrangler dev --config examples/nav-site/wrangler.jsoncIn another terminal, run node --test examples/nav-site/test/index.test.mjs; eight tests should pass. Use the README curl request to add a link and expect status 201. The first GET / must return x-nav-cache: MISS and the immediate second one HIT; after deleting the link, the home page must show MISS again with fresh data. A write without an Authorization header must return 401, and an invalid slug must return 400 invalid_slug.
Example authentication boundary
ADMIN_KEY only demonstrates a server-side Bearer check. A public deployment must store it as a Worker secret (see bindings and secrets), never inline it in code, commit it, or send it from browser code.
Architecture: a read-cache and write-invalidation loop
A navigation site is read-heavy and write-light, which makes it a clean demonstration of how KV and the Cache API divide the work: KV is the source of truth, the Cache API is the edge accelerator, and the admin API is the only write path.
| Path | Behavior | Consistency guarantee |
|---|---|---|
GET / | Checks caches.default first; on a miss, reads KV, renders HTML, and stores the page | Cache hits never touch KV |
GET /api/links | Reads KV and returns JSON, uncached | Always reflects current data |
POST /api/links | Validates ADMIN_KEY and input, writes KV, deletes the cached home page | The next visit after a write is fresh |
DELETE /api/links/{slug} | Validates, removes the entry from KV, deletes the cached home page | Same as above |
The write path is the only place that states a consistency rule: write KV first, then purge the cache, never the reverse. If you purge first, a read arriving between the two operations refills the cache with stale data.
Mature open-source references
Navigation sites are a repeatedly solved problem in the Cloudflare community. These projects represent distinct approaches and are worth reading before you build:
- NavSphere (800+ stars): uses a GitHub repository as the CMS, stores navigation data in the repo, and ships an admin UI plus one-click deployment to Cloudflare. A good fit for teams that want to manage links in a web UI without touching code.
- CF-Worker-Dir (650+ stars): a single-file Worker with the link configuration hard-coded as constants, so changing links means editing code. Mostly unmaintained since 2024, but the code is short and direct, which makes it a good first read on the minimal shape of a Worker rendering HTML.
- menav (around 300 stars): imports browser bookmarks and generates static pages at build time. A good fit for personal users whose link sets are stable and who accept a rebuild for every change.
This tutorial's example takes a different route: no GitHub CMS and no build pipeline. It demonstrates the KV plus Cache API loop of dynamic data with edge caching — data can change at any time through the API, the code invalidates the cache itself, every step is covered by runnable tests, and the closing section describes rollback when something goes wrong.
Data model and input validation
KV holds a single key, nav:links, whose value is a JSON document with the full link list. A navigation site holds at most a few hundred links, so one-key reads and writes are simpler than per-entry modeling and make "purge the cache once per write" unambiguous. All user input is validated before it reaches KV:
const SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
function validateLink(input) {
if (!SLUG.test(input.slug)) return { error: 'invalid_slug' };
const parsed = new URL(input.url);
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
return { error: 'invalid_url' };
}
// title is capped at 80 chars, category must be a lowercase slug, note at 200 chars
return { value: { slug: input.slug, title: input.title, url: parsed.toString() } };
}Rendering must HTML-escape fields such as title and note, and link URLs may only use the http: or https: protocol — that check also blocks javascript:-style injection. See examples/nav-site/src/index.mjs for the full implementation; every validation branch has a matching test.
Cache policy and active invalidation
The home page response carries cache-control: public, max-age=300 and is stored in caches.default. After a successful write, the code calls cache.delete() to invalidate actively, so admin changes are visible immediately.
The Cache API is per data center
Both the Cache API store and delete() apply only to the data center handling the request; there is no global synchronization guarantee. The max-age TTL is the safety net: even if a stale copy survives in some location, it expires on its own. Do not use this combination for pages that need second-level global consistency.
KV itself is eventually consistent, and global read propagation typically completes within a minute. For a navigation site, an edge-cache TTL fallback plus active invalidation after writes is enough; if your scenario needs stronger consistency, read the storage chooser before deciding whether to switch to D1.
Verification and troubleshooting
| Symptom | Check first | Recovery action |
|---|---|---|
401 unauthorized | Whether .dev.vars ADMIN_KEY matches the Authorization: Bearer value | Recopy the local template; never commit the value |
400 invalid_slug / invalid_url | Whether the slug is lowercase alphanumeric with hyphens and the URL is http(s) | Fix the request body; do not loosen the validation regex |
| Home page still stale after a change | Whether the x-nav-cache response header says HIT | Confirm the write returned 2xx; restart Wrangler locally to clear the emulated cache |
409 slug_conflict | Whether the slug already exists | Pick another slug, or DELETE before POST |
| Local KV reads return nothing | Whether Wrangler was started with --config examples/nav-site/wrangler.jsonc | Restart Wrangler with the example config |
Remote boundaries and rollback
Creating a remote KV namespace, running wrangler secret put ADMIN_KEY, replacing the placeholder ids in wrangler.jsonc with real ones, and wrangler deploy all modify Cloudflare account state, and this tutorial does not automate them. Rollback of code is a wrangler rollback or a redeploy of the previous version, but rollback cannot undo data already written to KV or recall a distributed secret. Because the example stores everything under the single nav:links key, a data rollback is equivalent to restoring that key's previous JSON — export a backup before significant changes; if a key leak is suspected, rotate the secret first, then restore stable code and audit the write logs.
Next step: bindings and secrets.
Official sources
Did this page help you complete your goal?
Beta feedback is generated in this browser and is never uploaded automatically.
Project three a Workers AI and Vectorize knowledge assistant
Build minimum RAG with multilingual embeddings, traceable metadata, protected ingestion, and an explicit no-context branch.
Project nine, URL shortener
Build a minimal short link service with a Worker and KV, with authenticated creation, 302 redirects, expiry, and click stats.