Adding Decap CMS to a Hugo Site on Cloudflare Pages
Update, October 2026: this site has since moved to Astro and no longer runs Decap CMS; this post stays as a record of that setup.
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.
Correction (2 November 2026, 18:45 Taipei): the OAuth callback now performs the authorizing:github handshake Decap expects and checks the origin and window.opener on the reply; a failed token exchange now posts authorization:github:error, and success is posted only with a non-empty access_token. Suggested by Codex (gpt-6-astra), checked by Claude.
This is a walkthrough of the exact setup I use on this site.
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. Put the CMS in the 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, exchanges the code for a token, and reports the result through Decap’s handshake:
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 });
}
let status = "error";
let content = { message: url.searchParams.get("error") || "missing code" };
if (code && !url.searchParams.get("error")) {
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 data = await res.json().catch(() => ({}));
if (res.ok && !data.error && typeof data.access_token === "string" && data.access_token) {
status = "success";
content = { token: data.access_token, provider: "github" };
} else {
content = { message: data.error_description || data.error || `HTTP ${res.status}` };
}
}
const message = `authorization:github:${status}:${JSON.stringify(content)}`;
const html = `
<script>
const allowed = window.location.origin;
const message = ${JSON.stringify(message).replace(/</g, "\\u003c")};
window.addEventListener("message", (e) => {
if (e.origin !== allowed || e.source !== window.opener) return;
if (e.data !== "authorizing:github") return;
window.opener.postMessage(message, allowed);
});
window.opener.postMessage("authorizing:github", allowed);
</script>`;
return new Response(html, { headers: { "Content-Type": "text/html" } });
}
Decap’s login popup expects a handshake first: the callback page posts authorizing:github to its opener, and only after the CMS echoes that string back does it accept a result starting with authorization:github:success: or authorization:github:error:. The reply is checked for both e.origin and e.source === window.opener, and messages from any other window are ignored. A denied authorisation, an expired code or an error from the token endpoint posts an error; success is posted only with a non-empty access_token.
The corrected callback was run on Node v22.23.4 against the Authenticator from decap-cms-lib-auth 3.3.4, with a fake opener and a fake token endpoint (output excerpt; result=null means the CMS received no result). This site no longer runs the CMS, so a real GitHub login has not been retested (unverified):
token ok: http=200 popupClosed=true result={"data":{"token":"gho_FAKE","provider":"github"}}
bad_verification_code: http=200 popupClosed=true result={"error":{"message":"The code passed is incorrect or expired."}}
token endpoint 500: http=200 popupClosed=true result={"error":{"message":"HTTP 500"}}
user denied: http=200 popupClosed=true result={"error":{"message":"access_denied"}}
CMS on other origin: http=200 popupClosed=false result=null
foreign echo ignored: http=200 popupClosed=true result={"data":{"token":"gho_FAKE","provider":"github"}}
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, and the browser silently drops the message. For local testing only, replace allowed with an allowlist that also includes your dev origin: send the handshake to each allowed origin, check that the reply’s e.origin is on the list, and post the result back to e.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 would change
- 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.
Notes
- Decap CMS lives as a static page under
static/shs/, reads and writes Markdown through the GitHub API, and needs no database or CMS server. - Cloudflare Pages Functions do the GitHub OAuth secret exchange; the callback posts the token only to
window.location.origin, and the client id and secret live in Pages environment variables. - Both languages share
content/posts, split into two collections by alanguagefrontmatter field and Decap’sfilter. - Pin
HUGO_VERSIONearly, skip placeholder translations, and keep built HTML out of the repo.
Written by Sheng with Claude’s help on prose and examples · part of the 2026-06-13 blog renovation, paint still drying.