Orange Book
End-to-end projects

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 ·

PROJECT 05INTERMEDIATEabout 55 minutesOutcome: draft, publication, and media loop

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

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

DataSuggested homeReason
Slug, title, body, stateD1Query, unique constraint, and publication state
Images, attachments, exportsR2Large objects read by key
Front-end JS/CSSStatic AssetsReleased with the application version
Short-lived public responseCache API/cache rulesReduce 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

Each page has a clear description, table of contents, and short conclusion.

Structured data

Use Article/Breadcrumb for matching content. FAQ is only for genuine question-and-answer content.

Media

Every image has dimensions, alt text, caption, and original link. Never generate a fake control panel.

Locales

Chinese and English share a translationKey, canonical policy, and visible translation status.

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

SymptomCheck firstRecovery
Admin endpoint returns 401EDITOR_KEY stays server-side and matches the headerRecreate local .dev.vars; never put the key in frontend code
Update or publish returns 409expectedVersion is still the current draft versionReload the draft and let the editor resolve the conflict
Upload returns 415MIME matches the JPEG/PNG/WebP/PDF signatureReject disguised files and export a supported format
Media returns media_unavailableThe R2 object referenced by D1 still existsStop 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.

On this page