Project five, content management CMS
Use D1 for content state, R2 for media, and a Worker for publication boundaries from draft to public page.
Edited and verified by Orange Book Editorial Team ·
Completion criteria
An editor can save a draft, preview, and publish. Public visitors read only a published version. Media objects have stable content references. Cache invalidation and code rollback cannot expose drafts or delete media accidentally.
Prerequisites and companion example
The example lives in examples/content-cms and requires Node.js 22, pnpm, and installed dependencies. Wrangler simulates local D1 and R2:
cp examples/content-cms/.dev.vars.example examples/content-cms/.dev.vars
pnpm wrangler d1 migrations apply orange-book-cms --local --config examples/content-cms/wrangler.jsonc
pnpm wrangler dev --config examples/content-cms/wrangler.jsoncRun node --test examples/content-cms/test/index.test.mjs; six tests should pass. Creating the README draft returns 201 and version: 1; publishing that version returns version: 2. The public query fixes status = 'published' in SQL, and a test proves that an R2 object is removed when its matching D1 write fails.
Minimum implementation boundary
This is a JSON content API and media lifecycle, not a pretend complete editor. A public CMS still needs real sessions, roles, audit history, signed preview access, and an editing interface.
Separate content and media
D1 stores article metadata, body, state, author, version, and publication time. R2 stores original images, PDF attachments, and export files. A Worker authenticates editor APIs, validates state transitions, and renders public reads. Application JS and CSS use Workers Static Assets instead of the business database.
| Data | Suggested home | Reason |
|---|---|---|
| Slug, title, body, state | D1 | Query, unique constraint, and publication state |
| Images, attachments, exports | R2 | Large objects read by key |
| Front-end JS/CSS | Static Assets | Released with the application version |
| Short-lived public response | Cache API/cache rules | Reduce repeat rendering, not the canonical state |
Minimum schema and read boundary
An article includes id, slug, status, title, body, version, published_at, and updated time. The public query limits status = 'published' in SQL. Do not read a draft and rely on the browser to hide it.
const article = await env.DB.prepare(
`SELECT slug, title, body, version, published_at
FROM articles
WHERE slug = ?1 AND status = 'published'`
).bind(slug).first();
if (!article) return Response.json({ error: 'not_found' }, { status: 404 });Administrative reads use a separate authenticated route with author/editor authorization. Public errors do not reveal internal IDs, draft body, or SQL.
Draft to publication
Save a draft
Bound slug, title, and body length on the server and use prepared statements. Include version or update time in concurrency control so two editors do not silently overwrite each other.
Upload media
Validate type, size, and object key before writing R2. D1 stores controlled key, media type, size, alt text, and owner. The server maps a public URL; it does not accept any remote URL as a private fetch proxy.
Preview
Require authentication or a short-lived signature. Mark the response so it is not publicly cached. Display draft version so the editor knows which save is visible.
Publish
Validate the state transition and role, write published_at, and increase version. Public reads return only published content, and cache keys account for URL and version.
Verify invalidation
Request the previous cache key and new version URL after publishing. Prove that stale body is not served. Cache API operations have data-center-local semantics, so one local delete is not a global business rollback. D1 remains the canonical publication state.
Reading experience and SEO
Content summary
Structured data
Media
Locales
Security and failure paths
Authorize edit, publish, delete, and upload separately. Login and publish receive Turnstile or rate controls plus application auditing. Test empty slug, duplicate slug, oversized body, non-editor access, public draft read, R2 failure, and D1 failure. Writing R2 first can leave an orphan if D1 fails and needs retryable cleanup. Writing D1 first requires a media-not-ready state.
Verification and troubleshooting
| Symptom | Check first | Recovery |
|---|---|---|
Admin endpoint returns 401 | EDITOR_KEY stays server-side and matches the header | Recreate local .dev.vars; never put the key in frontend code |
Update or publish returns 409 | expectedVersion is still the current draft version | Reload the draft and let the editor resolve the conflict |
Upload returns 415 | MIME matches the JPEG/PNG/WebP/PDF signature | Reject disguised files and export a supported format |
Media returns media_unavailable | The R2 object referenced by D1 still exists | Stop public references and restore from a versioned source |
Remote boundary and rollback
Remote D1 migrations, R2 buckets, domains, and cache rules need separate confirmation. Old code must remain compatible with the new content schema. Content rollback creates a new published version pointing to the last stable body instead of deleting history. Media deletion is delayed and checks references. On accidental publication, remove public state and invalidate cache first while preserving an audit record.
Next: choose a search approach for the CMS.
Primary sources
Did this page help you complete your goal?
Beta feedback is generated in this browser and is never uploaded automatically.
Project four, multi-tenant SaaS foundation
Complete a minimum SaaS loop with a shared Worker, D1 tenant boundaries, quotas, and optional customer domains.
Project six, image converter
Use R2 for originals, Cloudflare Images for supported transformations, and explicit parameter, source, cost, and cache boundaries.