Engineering

Sveltia CMS on Cloudflare Pages: the working setup

We replaced Decap CMS with Sveltia for a multi-collection content site running on Cloudflare Pages. Here's exactly what works, what broke, and the configuration that ended up shipping.

D2X Enterprises

We needed a headless CMS for a content-heavy Astro site on Cloudflare Pages. The constraints were tight: it had to be free, it had to work with Git as the storage backend, it had to handle multiple content collections with custom field types, and it could not require us to deploy a separate OAuth handshake server.

Decap CMS is the obvious starting point - it’s the spiritual successor to Netlify CMS, it’s actively maintained, and it has a healthy plugin ecosystem. We tried it. It half-worked.

Sveltia CMS is the route we ended up shipping. Here’s the experiment write-up.

What we tried first: Decap

Decap CMS is fully Git-backed and has a solid feature set. We dropped it into /admin on the site, configured config.yml for our collections, and pointed it at the GitHub repo as a backend.

The blocker was authentication. Decap’s GitHub OAuth flow requires a separate OAuth proxy service to complete the handshake - GitHub doesn’t allow OAuth directly from a static site origin. The official documentation suggests deploying that proxy to Netlify, Vercel, or Heroku.

We don’t run on those. We run on Cloudflare Pages plus the occasional Cloudflare Worker. We could have written the OAuth proxy as a Worker - it’s not hard - but it’s an extra moving part in the deploy chain we didn’t want.

We also tried the postMessage handshake hack that some teams use to skip the proxy. That works in development. In production, behind Cloudflare’s caching and security headers, the postMessage flow failed silently. We spent an evening debugging it before deciding the path was wrong.

What we shipped: Sveltia + PAT

Sveltia CMS is API-compatible with Decap’s config.yml format, which means our existing collection definitions ported over without changes. The key win is its authentication model: Sveltia accepts a GitHub Personal Access Token directly. No OAuth proxy. No Worker. Token in, browser stores it, you’re editing.

The trade-off: a PAT is broader than an OAuth token. For solo admin scenarios where you’re the only editor, that’s fine. For multi-editor setups, it’s worth thinking about - though Sveltia also supports the same OAuth flow as Decap if you want to set the proxy up later.

The configuration that works

The full config.yml lives alongside index.html in /public/admin/ and gets served as a static file on Cloudflare Pages. The shape of it:

backend:
  name: github
  repo: yourorg/yourrepo
  branch: main

publish_mode: editorial_workflow
site_url: https://your-site.com
display_url: https://your-site.com

media_libraries:
  s3:
    config:
      service_url: https://YOUR-ACCOUNT.r2.cloudflarestorage.com
      region: auto
      bucket: your-bucket
      access_key_id: YOUR_R2_ACCESS_KEY
      secret_access_key: YOUR_R2_SECRET
      public_url: https://images.your-site.com

collections:
  - name: posts
    folder: src/content/posts
    create: true
    fields:
      - { label: Title, name: title, widget: string }
      ...

Two things worth knowing.

First, Sveltia’s S3 media library config works with Cloudflare R2 because R2 implements the S3 API. Generate the R2 access key from the Cloudflare dashboard, drop it in, and image uploads from the CMS go straight to R2. Serve them from a custom subdomain that’s CNAME’d to the bucket, and you have a free image CDN.

Second, the publish_mode: editorial_workflow setting is what gives you the draft-pull-request flow. Edits create branches, the CMS shows you a kanban-style review board, and “publish” merges the branch. This is genuinely good for solo work because it gives you a trivial way to work-in-progress without polluting main.

What broke along the way

Sveltia’s media library was configured correctly on the first try, but the bucket’s CORS policy was not. Without the right CORS headers on the R2 bucket, the upload preflight failed and Sveltia showed a generic “upload failed” message with no useful detail. Setting Access-Control-Allow-Origin to the admin’s origin in the R2 bucket settings fixed it immediately.

The other gotcha: the GitHub PAT needs repo scope, and on a fine-grained token, you have to grant Contents: read and write, Metadata: read-only, and Pull requests: read and write. Miss any of those and the editorial workflow either fails to create branches or fails to merge them.

The result

Editor opens /admin, pastes a PAT once, then edits content. Saves create branches. Publishes merge to main and trigger a Cloudflare Pages build. Images upload to R2 and appear in the editor’s media picker. No OAuth proxy, no separate deploy chain, no costs at our scale.

The whole stack is free up to the limits of a small content site, which means our cost-of-ownership is purely the time to maintain it.

Reuse this

If you’re building a Cloudflare-based site and want a CMS that doesn’t require a backing OAuth service, Sveltia + PAT is the path. We’ve now shipped it on three sites with the same pattern.

Leaderboard (728x90 / responsive)
Ad Space Available
Advertise here →