sellIntegration
rss_feed

Blueprint: a blog / CMS

See how Pages, Workers, D1, R2, KV and Images fit together to power a real content site.

6Cloudflare products
4Database tables
1Worker API
~0msCache reads at the edge
insights

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.

schemaSystem architecture

page HTML

fetch /api

calls

SQL query

cache get/put

object key

img src

origin pull

Browser

Pages front-end

Worker API

D1 posts and authors

KV cache

R2 images

Images resize

restaurant

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).

account_tree

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.

web

Pages — front-end

Hosts your HTML, CSS and JavaScript and serves the blog UI globally. This is what readers actually open in the browser.

bolt

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.

database

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.

inventory_2

R2 — file storage

Object storage for big binary files: cover photos, uploads, attachments. The database only stores the object key; the bytes live here.

cached

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.

auto_fix_high

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.

schema

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.

schemaEntity relationship diagram

writes

has

labels

AUTHORS

int

id

PK

text

name

text

email

POSTS

int

id

PK

int

author_id

FK

text

title

text

slug

text

cover_key

int

published

POST_TAGS

int

post_id

FK

int

tag_id

FK

TAGS

int

id

PK

text

name

text

slug

sqlschema.sql
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)
);
swap_vert

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.

schemaRead flow: KV cache hit vs miss
"D1""KV cache""Worker API""Browser""D1""KV cache""Worker API""Browser"alt[cache hit][cache miss]GET /api/posts/helloread cache keycached JSONJSON x-cache=HITemptySELECT post + authorrowwrite cache TTL=300sJSON x-cache=MISS

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.

schemaPublish flow
"KV cache""D1""R2""Worker API""Author""KV cache""D1""R2""Worker API""Author"POST /api/posts + imagestore cover imageobject keyINSERT post and post_tagsnew post iddelete cached post201 Created
construction

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.

  1. Create the storage resources

    One command each for the database, the cache namespace, and the image bucket. Copy the IDs Wrangler prints.

    bash
    npx wrangler d1 create blog
    npx wrangler kv namespace create CACHE
    npx wrangler r2 bucket create blog-images
  2. Declare the bindings

    Put all three bindings in wrangler.jsonc. Now env.DB, env.CACHE and env.BUCKET are available inside the Worker.

    json
    {
      "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" }
      ]
    }
  3. Apply the database schema

    Run the schema.sql from the data-model section against the real cloud database with --remote.

    bash
    npx wrangler d1 execute blog --remote --file=./schema.sql
  4. Write 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.

    js
    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" },
        });
      },
    };
  5. 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.

    js
    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());
  6. Deploy

    Publish the Worker; connect your repo to Pages for the front-end. The whole blog is now live on Cloudflare's edge.

    bash
    npx wrangler deploy
school

Key concepts

cable

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.

key

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.

delete_sweep

Invalidate on write

When a post changes, delete its KV key. Otherwise readers keep seeing the stale cached version until the TTL runs out.

category

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.

tune

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.

dynamic_feed

Pages + Worker split

Pages serves static UI; the Worker serves /api. They deploy independently, so a UI tweak never risks your API.

tips_and_updates

Tips & pitfalls

payments

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.