Decap CMS 3.12 in 30 lines: what test-repo actually saves, and what it doesn't
Decap CMS (the project formerly known as Netlify CMS, forked in February 2023 after Netlify stopped maintaining it) is unusual: it's a single 5.6 MB JavaScript bundle that turns any folder with an admin/index.html into a content editor. Setup looks trivial. The friction is hidden one layer down — most people's first run uses the test-repo backend, and "Save" doesn't write anywhere they expect. Here's what we ran, what we screenshotted, and the localStorage keys that explain it.
Demo: SimpleReview on a Decap CMS issue
We're the team behind SimpleReview, a Chrome extension that turns the element you click on a broken admin into a draft code-fix PR. We're not affiliated with the Decap CMS project or Netlify. This is a deployment note from one real local install on one Linux box (kernel 5.15, Docker 24, Node served via python3 -m http.server) on the date above. If we got something wrong, open a GitHub issue and we'll fix the page.
The 30-line static site that boots a CMS
Decap is a React + Redux app shipped as one webpack bundle. There's no server-side install. The "site" is two files in an admin/ folder, plus whatever static host you already have. Ours:
$ tree /tmp/decap-test
/tmp/decap-test
├── admin
│ ├── config.yml
│ └── index.html
└── index.html
The admin/index.html is twelve lines including the doctype:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Content Manager</title>
</head>
<body>
<script src="https://unpkg.com/decap-cms@^3.0.0/dist/decap-cms.js"></script>
</body>
</html>
And admin/config.yml declares the backend and one collection:
backend:
name: test-repo
media_folder: "static/uploads"
public_folder: "/uploads"
collections:
- name: "blog"
label: "Blog"
folder: "content/blog"
create: true
fields:
- { label: "Title", name: "title", widget: "string" }
- { label: "Publish Date", name: "date", widget: "datetime" }
- { label: "Body", name: "body", widget: "markdown" }
- name: "pages"
label: "Pages"
folder: "content/pages"
create: true
fields:
- { label: "Title", name: "title", widget: "string" }
- { label: "Body", name: "body", widget: "markdown" }
Serve it with anything that hands back static files. We used Python's stdlib:
$ cd /tmp/decap-test && python3 -m http.server 18093
Serving HTTP on 0.0.0.0 port 18093 (http://0.0.0.0:18093/) ...
Open http://localhost:18093/admin/ and the bundle paints a login screen four seconds later:
/admin/ with the test-repo backend. The pink logo is hard to miss; the friction is also hard to miss — there's no username, no password, no field. Just "Login". This is the whole login screen for test-repo: clicking the button accepts you as a fictional user.What "test-repo" backend actually means
You'd assume from the name that test-repo writes to a local repo. It doesn't. There is no repo. The Decap docs describe it as "a backend that lives entirely in the browser, useful for offline demos", but in practice that wording underplays it. Click "Login", click New Blog, fill in a title — the editor flashes CHANGES SAVED in the toolbar before you've typed anything:
CHANGES SAVED pill is already visible. Decap is autosaving an entry that has no title, no date, no body. The "save" target, however, is not your collection folder.Open DevTools and look at localStorage:
localStorage contents on http://localhost:18093. The decap-cms-user key is the entire authentication state for test-repo — a literal JSON blob {"backendName":"test-repo"}. Clear the storage and you're "logged out" instantly. The collection drafts you create live in the same storage; nothing is written to content/blog/ on disk.With backend.name: test-repo, Decap stores entries, drafts, and user identity in browser localStorage. CHANGES SAVED means "saved to your tab's storage". Closing the tab keeps the data; clearing site data destroys it. There is no commit, no API call, no file write. The collection folder: content/blog in the config is the path Decap would use against a real backend; with test-repo it's only a label.
This is not a bug — it's the documented behaviour of test-repo. But it's a pothole if you're new: people demo Decap to a client, the client types a draft in the demo, the team migrates the config to backend.name: github, and the draft is gone.
The dashboard, once you're past the login
Click "Login", and the dashboard renders. Two collections from the YAML on the left, "Test Backend ↗" pinned to the top right as a permanent reminder of which backend is in use:
config.yml, an empty Blog list, and the Test Backend ↗ tag on the right. The "↗" arrow links to the Decap docs page about test-repo — we like that detail; most apps don't tell you what mode you're in this loudly.Visually, this is closer to a 2018 React admin than a 2026 one. The widget set (Markdown body, datetime, image, list, relation, file) is solid; the polish is uneven. The Markdown widget has a Rich Text/Markdown toggle that genuinely round-trips; the date widget is the unstyled HTML <input type="date"> with a "Now" / "Clear" pair welded on. We'll take it.
Switching to a real backend: GitHub friction
Swap config.yml for the real backend and re-load:
backend:
name: github
repo: example-org/example-decap-site
branch: main
The login screen changes — same Decap logo, but the button is now "Login with GitHub":
Click that button without an OAuth provider, and the popup goes nowhere — Decap doesn't ship its own OAuth server, because GitHub doesn't allow client-side OAuth: the token has to come from a confidential client. Your options as of 2026:
- Netlify Identity / Git Gateway. Easiest, but means standing up a Netlify project even if your hosting isn't on Netlify. The Netlify-Decap split in 2023 didn't break this integration, but it does feel like a vestigial limb on what's now a third-party project.
- Cloudflare Workers OAuth proxy. A community pattern (the popular one is decap-proxy): a Worker that holds the GitHub client secret and exchanges the code for a token. Two env vars, one Worker. Once running, free-tier traffic costs nothing.
- Self-host the OAuth server. The Decap repo includes
oauth-providercode; deploy it on Render / Fly / your own VPS. More moving parts, but no third-party dependency. - Switch to
git-gateway. Same Netlify-bundled service that ran for Netlify CMS, still works for Decap. Requires Netlify Identity.
None of these are documented prominently on the Decap site's getting-started flow. The path you actually land on after clicking "Login with GitHub" without setup is a popup that opens the OAuth URL with no client_id, GitHub returns a generic error page, and the popup closes. The Decap admin tab is unchanged. There's no toast, no console error in the parent window. You're left wondering whether to debug your OAuth provider or your config.
What we measured
| Metric | Value | Notes |
|---|---|---|
| Bundle (HTTP, gzipped) | 1.65 MiB | What the browser actually downloads from unpkg |
| Bundle (uncompressed) | 5.66 MiB | Size on disk after Content-Encoding decode |
| Cold load (one fetch) | 0.5 – 0.6 s | EU server to cf-cache HIT on Cloudflare; first paint adds ~1 s of React boot |
| Login → dashboard (test-repo) | ~2 s | No network, just Redux store init |
| Backend file I/O for content | 0 bytes | Test-repo writes only to localStorage |
| Admin source files needed | 2 | admin/index.html + admin/config.yml |
For a client-side-only CMS the bundle is fine. 1.65 MiB on the wire is roughly two typical hero images; gzipped JS doesn't compete with image weight on real content sites. The number that matters is the second visit — once the bundle's in HTTP cache, Decap boots in well under a second, which is the experience editors actually have day-to-day.
Quiet warnings worth knowing about
- The config is fetched, not bundled. Decap reads
/admin/config.ymlwith afetch()call. Cache-busting matters when you edit the YAML — we hit a 304 once and spent ten minutes wondering why a renamed field wasn't appearing. - You can't drop in a new collection without a hard refresh. Even though Decap polls
config.ymlon focus, runtime config changes don't propagate cleanly. Editors complaining "I don't see the new field" usually need a Cmd-Shift-R. - Pin a version in production. The
@^3.0.0selector we used resolves to the latest 3.x at request time (3.12.2 today). For real sites use@3.12.2exactly — Decap minor releases have introduced editor regressions before. unpkg redirects^3.0.0with HTTP 302 →/[email protected]/dist/decap-cms.js, which is fine for hobby sites and exactly what you don't want for client work. - Editorial Workflow needs a real backend. The
publish_mode: editorial_workflowsetting (PR-based draft/review/publish) is the killer feature, but it's a no-op undertest-repo. You can't validate the review flow without already having OAuth and a real repo wired up, which is exactly when the bug rate spikes for new installs. - Image uploads under
test-repogo nowhere. Same story as text drafts: the file lives in browser memory while the tab is open, then it's gone. The Media Library tab will sit at "No assets found" between sessions, which is correct but disorienting for first-time users.
The fix the README could ship today
- Rename
test-repoin the UI. "Test Backend ↗" is a start — but the dashboard could carry a yellow strip: "Drafts saved here are stored in your browser only and will be lost when site data is cleared. To save to a real repo, configure a Git backend." Anyone who has demoed Decap to a client has been bitten by the absence of this line. - Surface the OAuth failure mode. When the popup closes after a GitHub OAuth bounce-back without setup, the parent admin should toast: "Couldn't reach OAuth provider — see
backend.base_urlin config." Right now the silence reads as "this is just slow". - Bundle a one-page recipe for Cloudflare Workers OAuth. The community proxy is the de-facto standard now that Netlify's not the only host. A Decap-blessed copy of the Worker code, with the right
backend.base_urlsnippet, would cut the average new-install time to working state by an hour, easily. - Pin the version in
create-decap-cms-appoutput. Templates that scaffold<script src="...^3.0.0...">bake in a future regression risk.
Where Decap fits in 2026
Decap is the right tool for a specific narrow case: a small site (Hugo, Eleventy, Next-static, Astro) where the editor is one or two technical-ish people, the source of truth is a Git repo, and pull-request-based review of content is genuinely useful. For that case, Decap's combination of zero backend, real Git history per edit, and free hosting on whatever already serves your site is unbeatable.
It's the wrong tool for a content team of ten with media-heavy uploads, role-based permissions, or non-Git editorial workflows. Strapi, Directus, or Payload CMS each handle that better. Ghost handles it better still if all you want is a blog. Where Decap wins is when "the CMS shouldn't add a server" is a hard requirement — that's still a real shape of project, especially for documentation sites and side-project blogs.
The test-repo default is the rough edge worth knowing about before you demo it. Once you've seen the localStorage screenshot above, the friction stops being mysterious and starts being something you can route around — either by skipping the demo and going straight to a Git backend with OAuth, or by warning the client up front that drafts in this view aren't real until the GitHub login screen replaces the test one.
Where this fits
One short, honest write-up per self-hostable CMS / LLM / booking tool we run on a real Linux box. Adjacent articles in the same series: Ghost 5 on Docker SQLite, Open WebUI on Linux Docker, Dify 1.14.0 docker-compose, Cal.com 6.16.1 self-host, PostHog hobby self-host. SimpleReview is the Chrome extension that turns whatever element you click on a broken admin — a misbehaving Decap collection editor included — into a draft code-fix PR you can ship without leaving the page.