Adding Decap CMS to a Hugo Site on Cloudflare Pages
Adding Decap CMS to a Hugo Site on Cloudflare Pages
Hugo is fast and flexible, but editing Markdown files directly isn’t for everyone — especially when you want a visual editor or you’re collaborating with non-engineers. Decap CMS (formerly Netlify CMS) slots neatly into a Hugo workflow: it lives as a static page in your repo, reads and writes Markdown through the GitHub API, and requires no backend server.
This is a walkthrough of the exact setup I use on this site.
The Architecture
Browser → /shs/ (Decap CMS SPA)
↓ GitHub OAuth
Cloudflare Pages Function (/api/auth, /api/auth/callback)
↓ GitHub API
content/posts/*.{zh,en}.md in your repo
↓ git push
Cloudflare Pages build (hugo --minify)
↓
public/ served at your domain
No content database. No CMS server. Everything in Git.
1. Drop the CMS into Your Static Folder
Create two files:
static/shs/index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Content Manager</title>
</head>
<body>
<script src="https://unpkg.com/decap-cms@^3.0.0/dist/decap-cms.js"></script>
</body>
</html>
static/shs/config.yml
backend:
name: github
repo: your-username/your-repo
branch: main
base_url: https://your-domain.com
auth_endpoint: /api/auth
media_folder: static/images/uploads
public_folder: /images/uploads
collections:
- name: posts_en
label: Posts (English)
folder: content/posts
create: true
extension: md
format: frontmatter
slug: "{{slug}}.en"
filter:
field: language
value: en
fields:
- { label: Title, name: title, widget: string }
- { label: Date, name: date, widget: datetime }
- { label: Draft, name: draft, widget: boolean, default: true }
- { label: Description, name: description, widget: string, required: false }
- { label: Tags, name: tags, widget: list, required: false }
- { label: Body, name: body, widget: markdown }
- { label: Language, name: language, widget: hidden, default: en }
Hugo’s static/ directory copies everything to public/ verbatim, so /shs/ is available immediately after build.
2. OAuth via Cloudflare Pages Functions
GitHub’s OAuth requires a server-side secret exchange — you can’t do it in the browser without exposing your client_secret. Cloudflare Pages Functions handle this without a separate worker.
functions/api/auth.js — initiates the OAuth flow and sets a state cookie:
export async function onRequest({ request, env }) {
const state = crypto.randomUUID();
const url = `https://github.com/login/oauth/authorize?client_id=${env.GITHUB_CLIENT_ID}&scope=repo,user&state=${state}`;
return new Response(null, {
status: 302,
headers: {
Location: url,
"Set-Cookie": `oauth_state=${state}; Path=/; HttpOnly; Secure; SameSite=Lax`,
},
});
}
functions/api/auth/callback.js — validates the state and exchanges code for token:
export async function onRequest({ request, env }) {
const url = new URL(request.url);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
const cookieHeader = request.headers.get("Cookie") || "";
const storedState = cookieHeader.match(/(?:^|;\s*)oauth_state=([^;]+)/)?.[1];
if (!state || state !== storedState) {
return new Response("Invalid state parameter", { status: 403 });
}
const res = await fetch("https://github.com/login/oauth/access_token", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({
client_id: env.GITHUB_CLIENT_ID,
client_secret: env.GITHUB_CLIENT_SECRET,
code,
}),
});
const { access_token } = await res.json();
const html = `
<script>
window.opener.postMessage(
'authorization:github:success:${JSON.stringify({ token: access_token, provider: "github" })}',
window.location.origin
);
</script>`;
return new Response(html, { headers: { "Content-Type": "text/html" } });
}
That window.location.origin target stops the token leaking to any other origin. In production the CMS admin and this callback share the same Pages origin, so the message lands. In local dev the CMS runs on a different origin (e.g. http://localhost:1313) while the callback still runs on your production Pages URL — the browser silently drops the message, so for local testing only, post to an allowlist that also includes your dev origin.
Set GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET in Cloudflare Pages → Settings → Environment variables. Never put them in the repo.
3. Register the GitHub OAuth App
Go to GitHub → Settings → Developer settings → OAuth Apps → New OAuth App:
- Homepage URL:
https://your-domain.com - Authorization callback URL:
https://your-domain.com/api/auth/callback
4. Cloudflare Pages Build Settings
| Setting | Value |
|---|---|
| Build command | hugo --minify |
| Build output directory | public |
HUGO_VERSION env var | 0.160.0 (pin this to avoid surprise breaks) |
Hugo’s themes/PaperMod/ should be a git submodule so Cloudflare can fetch it during build:
git submodule add --depth=1 https://github.com/adityatelange/hugo-PaperMod themes/PaperMod
5. Bilingual Collections
The trick for managing foo.zh.md / foo.en.md in the same folder: add a language frontmatter field and use Decap’s filter.
Both collections point at content/posts, but:
posts_zhfilters onlanguage: zh, slug template"{{slug}}.zh"posts_enfilters onlanguage: en, slug template"{{slug}}.en"
A new post created from posts_zh gets a hidden language: zh field and is saved as my-post.zh.md. Hugo automatically pairs it with my-post.en.md as a translation.
What I’d Do Differently
- Skip the bilingual placeholder posts. If a translation doesn’t exist, just don’t create the file. Hugo handles single-language posts cleanly without erroring.
- Pin
HUGO_VERSIONearly. Without it, Cloudflare defaults to an old Hugo version and your config keys may not be recognised. - Don’t store the built HTML in the repo. Serve directly from the Hugo source and let Cloudflare build it. Storing build output creates a confusing repo history and breaks tools like Decap CMS that expect Markdown, not HTML.
Part of the 2026-06-13 blog renovation, paint still drying.