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:
export async function onRequest({ request, env }) {
const clientId = env.GITHUB_CLIENT_ID;
const scope = "repo,user";
const state = crypto.randomUUID();
const url = `https://github.com/login/oauth/authorize?client_id=${clientId}&scope=${scope}&state=${state}`;
return Response.redirect(url, 302);
}
functions/api/auth/callback.js — exchanges code for token:
export async function onRequest({ request, env }) {
const { searchParams } = new URL(request.url);
const code = searchParams.get("code");
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" })}',
'*'
);
</script>`;
return new Response(html, { headers: { "Content-Type": "text/html" } });
}
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.