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