Building a Blog on Cloudflare Workers + D1 + Next.js: Architecture and Design Decisions
# Using this blog itself as the case study: the architecture, design decisions, and pitfalls of running a personal blog on Cloudflare Workers with D1, R2, and a Next.js-compatible runtime.
Turnstile site key が未設定のため、このフォームは送信できません。管理者は NEXT_PUBLIC_TURNSTILE_SITE_KEY を設定してください。
[ ]まだコメントはありません。
What this covers
This blog runs on Cloudflare Workers. Posts live in D1 (SQLite), images in R2, and the framework is vinext, a Next.js-compatible runtime. Here is why I chose this stack and what I learned operating it — the real notes, not marketing.
This is for anyone who wants a personal blog without babysitting a VPS.
The key property: everything lives in one Worker. SSR, the API, and image delivery share a single entry point, so there is no service-to-service networking and no CORS to think about.
Why D1
A personal blog is read-heavy and write-rare — exactly what SQLite is good at, and D1's free tier is generous.
The schema is deliberately simple:
sql
CREATE TABLE posts ( id TEXT PRIMARY KEY, slug TEXT UNIQUE NOT NULL, title TEXT NOT NULL, excerpt TEXT NOT NULL, content TEXT NOT NULL, tags TEXT NOT NULL, -- stored as a JSON string status TEXT NOT NULL, -- draft / published likes INTEGER DEFAULT 0, views INTEGER DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
Storing tags as a JSON string looks lazy, but for a blog with hundreds of posts it is plenty. When tag aggregation gets heavy, read only SELECT tags FROM posts WHERE status='published' and aggregate in the app — the trick is not reading the content column.
Caveat: D1 has no connection pool and no long transactions. Read-then-write races are real, so writes should use INSERT ... ON CONFLICT DO UPDATE or optimistic locking (UPDATE ... WHERE updated_at = ?).
Why R2 + the Images binding
Thumbnails live in R2 and get resized at delivery time via the Images binding. Uploads are also compressed client-side first:
ts
const bitmap = await createImageBitmap(file);const canvas = document.createElement("canvas");// shrink to max width 1600px, re-encode as WebP 0.82const blob = await new Promise((r) => canvas.toBlob(r, "image/webp", 0.82));
"Why not do it on the server?" — because Workers CPU time is the scarce resource. Anything the client can do, the client should do.
Running Next.js on Workers
There are several ways to run Next.js on Workers; this blog uses vinext, a Vite-based Next.js-compatible runtime. App Router, Server Components, and next/image-equivalent optimization all work.
One warning: compatibility layers pin an exact Next.js version. Pin it exactly in package.json (no ^) and upgrade the runtime and framework as a pair.
The double life of environment variables
On Workers, configuration exists in two worlds: build time (process.env) and runtime (the env binding). NEXT_PUBLIC_* values are inlined into client code at build time, but anything that only exists at runtime needs a helper that checks both:
Without this you will eventually hit "works locally, env var is empty in production." I did.
Cost
For personal-blog traffic this stack costs effectively zero: the Workers free tier (100k requests/day), D1 free tier (5 GB), and R2 free tier (10 GB) cover it. The domain is the only real bill.
Takeaways
• One Worker for SSR + API + images makes the architecture radically simpler
• D1 is enough for a personal blog — but bring your own optimistic locking
• Compress images on the client, transform at delivery with the Images binding
• Write the build-time/runtime env helper on day one
Next up: Japanese full-text search on D1 with FTS5.
Turnstile site key が未設定のため、このフォームは送信できません。管理者は NEXT_PUBLIC_TURNSTILE_SITE_KEY を設定してください。