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_zh filters on language: zh, slug template "{{slug}}.zh"
  • posts_en filters on language: 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_VERSION early. 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.