# Adding Decap CMS to a Hugo Site on Cloudflare Pages

> A practical guide to wiring up Decap CMS with a Hugo static site — GitHub backend, Cloudflare Pages Functions for OAuth, and bilingual collections.

Source: https://sheng.page/en/posts/adding-decap-cms-to-hugo-on-cloudflare-pages/ · Published: 2026-04-15 · Author: Sheng

> 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](https://decapcms.org/) (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.

## Architecture

```text
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`**
```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`**
```yaml
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:
```js
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:
```js
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:
```bash
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 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_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.

## 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 a `language` frontmatter field and Decap's `filter`.
- Pin `HUGO_VERSION` early, 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.