Blueprint: a blog / CMS
See how Pages, Workers, D1, R2, KV and Images fit together to power a real content site.
What are we building?
A blog or CMS (Content Management System = the software that stores and serves your articles) needs four jobs done: show pages to readers, run an API, store the text, and store the images. Instead of one big server doing everything, Cloudflare splits these jobs across small, focused services that all run at the edge (the edge = data centres close to your readers).
This page is a 'see the whole system' map. The diagram below wires every piece together: the reader's browser talks to Pages (the front-end) and to a Worker (the API); the Worker reads articles from D1, images from R2, and a fast cache from KV; and the browser pulls optimised pictures through Images.
Think of it like a restaurant
Pages is the dining room (what guests see), the Worker is the waiter (takes orders, brings food), D1 is the recipe book (structured text), R2 is the pantry (raw ingredients / files), KV is the dish kept warm on the pass (instant re-serve), and Images is the plating station (resizes each photo to order).
Who does what?
Each Cloudflare product has one clear role. Keeping them separate is what makes the system cheap, fast, and easy to reason about — you can change the cache without touching the database, or swap the front-end without rewriting the API.
Pages — front-end
Hosts your HTML, CSS and JavaScript and serves the blog UI globally. This is what readers actually open in the browser.
Workers — the API
The brain. It receives /api requests, decides what to read or write, talks to D1, R2 and KV, and returns JSON. All your business logic lives here.
D1 — structured data
A SQL database holding posts, authors and tags — anything with relationships you query with SELECT/INSERT. The source of truth for text.
R2 — file storage
Object storage for big binary files: cover photos, uploads, attachments. The database only stores the object key; the bytes live here.
KV — read cache
A super-fast key→value store used to remember the JSON of popular posts, so repeat reads skip the database entirely. Cleared when a post changes.
Images — optimisation
Resizes, crops and re-formats photos on the fly from a URL, so every device gets the right size without you storing many copies.
The data model
Four tables describe a blog. One author writes many posts. One post can carry many tags, and one tag can appear on many posts — that 'many-to-many' relationship needs a join table called post_tags. Notice posts only stores cover_key (a pointer into R2), not the image bytes themselves.
CREATE TABLE authors (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE
);
CREATE TABLE posts (
id INTEGER PRIMARY KEY,
author_id INTEGER NOT NULL REFERENCES authors(id),
title TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
body TEXT NOT NULL,
cover_key TEXT,
published INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE tags (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL
);
CREATE TABLE post_tags (
post_id INTEGER NOT NULL REFERENCES posts(id),
tag_id INTEGER NOT NULL REFERENCES tags(id),
PRIMARY KEY (post_id, tag_id)
);Reading & publishing
Reading a post (with cache)
When someone opens an article, the Worker checks KV first. If the JSON is already there (a cache hit), it returns instantly and never touches the database. If not (a cache miss), it queries D1, builds the response, and saves it to KV for next time. This is why a popular post stays fast and cheap.
Publishing a post
Writing goes the other way. The author submits text plus a cover image; the Worker stores the file in R2, saves the row (and its tags) in D1, then deletes the cached copy in KV so readers immediately see the new version.
Build it: bindings & code
A binding is a named connector (like DB or CACHE) that you declare in wrangler.jsonc so your Worker can reach a resource as env.<NAME> — no connection strings or secrets in code. Below: create the resources, declare the bindings, define the schema, then the Worker and the front-end.
Create the storage resources
One command each for the database, the cache namespace, and the image bucket. Copy the IDs Wrangler prints.
npx wrangler d1 create blog npx wrangler kv namespace create CACHE npx wrangler r2 bucket create blog-imagesDeclare the bindings
Put all three bindings in wrangler.jsonc. Now env.DB, env.CACHE and env.BUCKET are available inside the Worker.
{ "name": "blog-api", "main": "src/index.js", "compatibility_date": "2025-01-01", "d1_databases": [ { "binding": "DB", "database_name": "blog", "database_id": "<your-d1-id>" } ], "kv_namespaces": [ { "binding": "CACHE", "id": "<your-kv-id>" } ], "r2_buckets": [ { "binding": "BUCKET", "bucket_name": "blog-images" } ] }Apply the database schema
Run the schema.sql from the data-model section against the real cloud database with --remote.
npx wrangler d1 execute blog --remote --file=./schema.sqlWrite the Worker API (read path)
This handler is the heart of the blueprint: KV first, then D1, then write back to KV. It also builds the Images URL from the R2 cover_key.
export default { async fetch(request, env) { const url = new URL(request.url); const slug = url.pathname.split("/").pop(); const cacheKey = `post:${slug}`; // 1) Try the KV cache first const cached = await env.CACHE.get(cacheKey); if (cached) { return new Response(cached, { headers: { "content-type": "application/json", "x-cache": "HIT" }, }); } // 2) Cache miss -> read post + author from D1 const post = await env.DB .prepare( "SELECT p.id, p.title, p.body, p.cover_key, a.name AS author " + "FROM posts p JOIN authors a ON a.id = p.author_id " + "WHERE p.slug = ? AND p.published = 1" ) .bind(slug) .first(); if (!post) return new Response("Not found", { status: 404 }); // 3) Build an optimised image URL (R2 object served via Images) post.cover_url = `https://img.example.com/cdn-cgi/image/width=1200/${post.cover_key}`; const json = JSON.stringify(post); // 4) Save to KV for 5 minutes so the next read is instant await env.CACHE.put(cacheKey, json, { expirationTtl: 300 }); return new Response(json, { headers: { "content-type": "application/json", "x-cache": "MISS" }, }); }, };Wire up the front-end
On Pages, plain fetch() to the Worker API is all you need. The page renders the title, author and the optimised cover image.
async function loadPost(slug) { const res = await fetch(`/api/posts/${slug}`); if (!res.ok) throw new Error("Post not found"); const post = await res.json(); document.querySelector("#title").textContent = post.title; document.querySelector("#author").textContent = "by " + post.author; document.querySelector("#cover").src = post.cover_url; document.querySelector("#body").innerHTML = post.body; } // Read the slug from the URL, e.g. /blog/hello-world loadPost(location.pathname.split("/").pop());Deploy
Publish the Worker; connect your repo to Pages for the front-end. The whole blog is now live on Cloudflare's edge.
npx wrangler deploy
Key concepts
Bindings, not connection strings
You reach D1, KV and R2 as env.DB / env.CACHE / env.BUCKET. There are no passwords in your code to leak.
Cache key & TTL
Each post is cached under post:<slug> with a TTL (time-to-live) — it auto-expires after 300 seconds even if nobody clears it.
Invalidate on write
When a post changes, delete its KV key. Otherwise readers keep seeing the stale cached version until the TTL runs out.
Right tool per data type
Relational text → D1; large files → R2; hot read cache → KV. Mixing them up (e.g. images in D1) gets slow and pricey.
Images by URL
Adding /cdn-cgi/image/width=1200/ to a URL resizes on the fly — store one original in R2, serve every size from it.
Pages + Worker split
Pages serves static UI; the Worker serves /api. They deploy independently, so a UI tweak never risks your API.
Tips & pitfalls
Generous free tiers stack up
D1 (5M rows read/day), KV (100K reads/day), R2 (10GB + zero egress fees) and Pages (unlimited static requests) each have free tiers — a small blog can run at no cost. The cache means most reads never even hit D1's quota.
- Always invalidate the KV key when you edit or unpublish a post, or readers see stale content.
- Store only the cover_key in D1 (a short string); keep the actual image bytes in R2.
- Add an index on posts(slug) — it is your main lookup column and keeps reads cheap.
- R2 has no egress fees, so serving images is far cheaper than typical cloud storage.
- Keep secrets (e.g. an admin token) in wrangler secrets, never in wrangler.jsonc.
Related products
menu_bookOfficial docsopen_in_new