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:

  1. User hits your API in us-east-1
  2. App talks to Postgres in the same region
  3. Media comes from S3 (egress fees optional, but common)
  4. Every region outside that AZ pays the latency tax

An edge-native CMS flips that:

  1. Request hits a Worker near the user
  2. D1 (SQLite at the edge) serves content/auth data
  3. R2 stores media with no egress fees
  4. 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 R2 MEDIA_BUCKET binding.
  • access.public: ['read'] is opt-in. Private-by-default is the right CMS default; public APIs should be explicit.
  • cache.ttl lets 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