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

SettingValue
Build commandhugo --minify
Build output directorypublic
HUGO_VERSION env var0.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.

Part of the 2026-06-13 blog renovation, paint still drying.