Most headless CMS setups still look like 2018: a Node server, a Postgres box in one region, and S3 for uploads. That works — until you want global latency, zero cold starts, and an ops surface you can explain in one diagram.
This series is about running a real headless CMS on Cloudflare Workers, with D1 for data and R2 for media, using SonicJS — an edge-first CMS built for that stack. Part 1 covers the architecture and content model. Part 2 covers deploy + CI/CD. Part 3 covers the auth and admin gotchas you’ll hit in production.
Why put a CMS on the edge?
A traditional CMS request path often looks like this:
- User hits your API in
us-east-1 - App talks to Postgres in the same region
- Media comes from S3 (egress fees optional, but common)
- Every region outside that AZ pays the latency tax
An edge-native CMS flips that:
- Request hits a Worker near the user
- D1 (SQLite at the edge) serves content/auth data
- R2 stores media with no egress fees
- The same Worker serves admin UI + REST API
SonicJS is designed around that model: TypeScript collections define your schema; the framework generates the admin UI and API; Wrangler binds D1 and R2 so you never invent connection strings for a CMS database.
The shape of the system
┌─────────────────────────────────────┐
│ Cloudflare Worker │
│ (SonicJS + Hono + admin UI) │
└──────────────┬──────────────────────┘
│
┌───────┴────────┐
▼ ▼
┌─────────────┐ ┌─────────────┐
│ D1 (SQLite)│ │ R2 (media) │
│ content/auth│ │ images/files│
└─────────────┘ └─────────────┘
Enter fullscreen mode Exit fullscreen mode
Bindings live in wrangler.toml — the Worker receives env.DB and env.MEDIA_BUCKET at runtime. No ORM connection URL. No S3 access keys in .env for the happy path.
name = "my-cms"
main = "src/index.ts"
compatibility_date = "2024-09-23"
compatibility_flags = ["nodejs_compat"]
[[d1_databases]]
binding = "DB"
database_name = "my-cms-db"
database_id = "YOUR_DATABASE_ID"
migrations_dir = "./node_modules/@sonicjs-cms/core/migrations"
[[r2_buckets]]
binding = "MEDIA_BUCKET"
bucket_name = "my-cms-media"
[vars]
ENVIRONMENT = "development"
BUCKET_NAME = "my-cms-media"
Enter fullscreen mode Exit fullscreen mode
Bootstrap: one file to start the app
The app entrypoint is small. You register collections, optionally tweak auth/plugins, and export the SonicJS app:
import { createSonicJSApp, registerCollections } from '@sonicjs-cms/core'
import type { SonicJSConfig } from '@sonicjs-cms/core'
import blogPostsCollection from './collections/blog-posts.collection'
registerCollections([blogPostsCollection])
const config: SonicJSConfig = {
plugins: {
register: [],
},
auth: {
// We'll dig into this in Part 3
extendBetterAuth: (opts) => ({
...opts,
emailAndPassword: {
...opts.emailAndPassword,
disableSignUp: true,
},
}),
},
}
export default createSonicJSApp(config)
Enter fullscreen mode Exit fullscreen mode
That’s the whole Worker surface: collections + config → admin at /admin, auth at /auth/login, health at /health, and a generated content API.
Schema-as-code: define a blog collection
Instead of clicking schema fields in a UI and hoping they sync to prod, you define collections in TypeScript. SonicJS treats them as managed (config-driven) collections.
Here’s a practical blog post model: title, slug, Lexical rich text, featured image + gallery on R2, author, publish date — with public read for your frontend and a short cache TTL:
import type { CollectionConfig } from '@sonicjs-cms/core'
export default {
name: 'blog_post',
displayName: 'Blog Post',
slug: 'blog-posts',
description: 'Manage your blog posts',
schema: {
type: 'object',
properties: {
title: {
type: 'string',
title: 'Title',
required: true,
maxLength: 200,
},
slug: {
type: 'slug',
title: 'URL Slug',
required: true,
maxLength: 200,
},
excerpt: {
type: 'textarea',
title: 'Excerpt',
maxLength: 300,
helpText: 'Short summary used in listings and SEO meta tags',
},
content: {
type: 'lexical',
title: 'Content',
required: true,
},
featuredImage: {
type: 'media',
title: 'Featured Image',
},
gallery: {
type: 'array',
title: 'Gallery',
items: { type: 'media' },
},
author: {
type: 'user',
title: 'Author',
required: true,
},
publishedAt: {
type: 'datetime',
title: 'Published Date',
},
},
required: ['title', 'slug', 'content', 'author'],
},
listFields: ['title', 'author', 'status', 'publishedAt'],
searchFields: ['title', 'excerpt', 'content', 'author'],
defaultSort: 'createdAt',
defaultSortOrder: 'desc',
managed: true,
isActive: true,
// Without this, only authenticated admins/editors can read via the API
access: {
public: ['read'],
},
cache: {
enabled: true,
ttl: 5, // seconds — tune for your freshness vs speed tradeoff
},
} satisfies CollectionConfig
Enter fullscreen mode Exit fullscreen mode
A few details worth copying into your own project:
-
type: 'media'maps uploads to the R2MEDIA_BUCKETbinding. -
access.public: ['read']is opt-in. Private-by-default is the right CMS default; public APIs should be explicit. -
cache.ttllets you override per collection when some content can be hotter than others.
Local development loop
With Wrangler, local feels like production: same bindings, Miniflare-backed D1/R2 under .wrangler/.
npm install
npm run db:migrate:local # apply SonicJS migrations to local D1
# seed an admin (credentials via env — never commit them)
ADMIN_EMAIL=[email protected] ADMIN_PASSWORD='your-secure-password' npm run seed
npm run dev # wrangler dev — usually http://localhost:8787
Enter fullscreen mode Exit fullscreen mode
Then open:
- Login:
http://localhost:8787/auth/login - Admin:
http://localhost:8787/admin - Health:
http://localhost:8787/health
You’re not “running a CMS in Docker and pretending it’s Cloudflare.” You’re running the Worker runtime locally with the same APIs you’ll use in prod.
When this stack shines
- Marketing / content sites that need a real admin, not a markdown-only repo
- Frontends (Astro, Next, Remix, plain fetch) that want a fast public JSON API
- Teams that prefer schema in git over schema-only-in-UI
- Projects that want Cloudflare’s free/paid tiers instead of a always-on Node + Postgres bill
What’s next
In Part 2, we’ll ship this to production: production wrangler.toml envs, secrets, D1 migrations, and a GitHub Actions pipeline that migrates, deploys, and smoke-checks /health.
Docs: SonicJS · Cloudflare Workers · D1 · R2
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.