DEV Community

Najmul Hasan
Najmul Hasan

Posted on Originally published at najmulhasan.navicore.co

GitHub Contents API CMS in Next.js: Publish Markdown Without a Database

You can publish from a browser without running a database: an admin form commits a Markdown file to your GitHub repo, and the host redeploys. This post shows that GitHub Contents API CMS pattern in Next.js 16 (App Router), the one rule that trips people up (sha), and how to handle a 409.

Status first: I built this admin and ran 41 API checks against mock GitHub, dev.to and IndexNow servers. I have not run it against the live services yet. Treat the code below as a pattern tested against mocks, not a battle-tested one.

The short answer

Commit with one request:

PUT /repos/{owner}/{repo}/contents/{path}
Enter fullscreen mode Exit fullscreen mode

Send message, content (the file, base64-encoded) and branch. For an update, also send the file's current sha. A new file needs no sha. A stale sha returns 409. Show that to the editor instead of retrying blindly. After the commit, Vercel redeploys the site.

You also get git history, diffs and rollback for every edit, because the content is just files in the repo.

Set up the GitHub Contents API as a CMS

Articles are Markdown files in content/articles/<slug>.md. The admin runs on the server side of the same Next.js app, so the GitHub token never reaches the browser.

Environment variables (names only):

GITHUB_TOKEN
GITHUB_OWNER
GITHUB_REPO
GITHUB_BRANCH
SITE_ORIGIN
Enter fullscreen mode Exit fullscreen mode

Use a fine-grained personal access token limited to this one repository, with Contents set to read and write (as of October 2026, verify the permission name in GitHub's settings). Never prefix it with NEXT_PUBLIC_.

Read and write a file

This is a minimal version written for Next.js 16. It is server-only code.

// lib/github-content.ts
const OWNER = process.env.GITHUB_OWNER;
const REPO = process.env.GITHUB_REPO;
const BRANCH = process.env.GITHUB_BRANCH || "main";
const base = `https://api.github.com/repos/${OWNER}/${REPO}/contents`;

function ghHeaders() {
  return {
    Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
    Accept: "application/vnd.github+json",
    "X-GitHub-Api-Version": "2022-11-28",
    "Content-Type": "application/json",
  };
}

export function isValidSlug(slug: unknown) {
  return typeof slug === "string" && /^[a-z0-9-]{1,80}$/.test(slug);
}

export async function readArticle(slug: string) {
  const res = await fetch(
    `${base}/content/articles/${slug}.md?ref=${BRANCH}`,
    { headers: ghHeaders(), cache: "no-store" }
  );
  if (res.status === 404) return null;
  if (!res.ok) throw new Error(`GitHub read failed: ${res.status}`);
  const data = await res.json();
  const markdown = Buffer.from(data.content, "base64").toString("utf8");
  return { markdown, sha: data.sha };
}

export async function writeArticle(slug: string, markdown: string, sha?: string) {
  const res = await fetch(`${base}/content/articles/${slug}.md`, {
    method: "PUT",
    headers: ghHeaders(),
    body: JSON.stringify({
      message: sha ? `Update article: ${slug}` : `Add article: ${slug}`,
      content: Buffer.from(markdown, "utf8").toString("base64"),
      branch: BRANCH,
      sha,
    }),
  });
  if (res.status === 409) return { conflict: true, sha: null };
  if (!res.ok) throw new Error(`GitHub write failed: ${res.status}`);
  const data = await res.json();
  return { conflict: false, sha: data.content.sha };
}
Enter fullscreen mode Exit fullscreen mode

JSON.stringify drops sha when it is undefined, so one function handles both create and update. Validate the slug before it becomes part of a path. That regex is what stops ../ tricks.

The route handler

// app/api/admin/articles/route.ts
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { verifySession } from "@/lib/session"; // your own signed-cookie check, not shown
import { isValidSlug, writeArticle } from "@/lib/github-content";

export async function PUT(req: Request) {
  if (req.headers.get("origin") !== process.env.SITE_ORIGIN) {
    return NextResponse.json({ error: "Bad origin" }, { status: 403 });
  }
  const store = await cookies();
  if (!verifySession(store.get("admin_session")?.value)) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const { slug, markdown, sha } = await req.json();
  if (!isValidSlug(slug) || typeof markdown !== "string" || markdown === "") {
    return NextResponse.json({ error: "Invalid input" }, { status: 400 });
  }

  try {
    const result = await writeArticle(slug, markdown, sha);
    if (result.conflict) {
      return NextResponse.json(
        { error: "File changed on GitHub. Reload before saving." },
        { status: 409 }
      );
    }
    return NextResponse.json({ sha: result.sha });
  } catch {
    return NextResponse.json({ error: "Publish failed" }, { status: 502 });
  }
}
Enter fullscreen mode Exit fullscreen mode

In Next.js 15 and later, cookies() is async, hence the await. This route has no dynamic segment. If you add one, params is a Promise too. Next.js 14 is sync for both.

The sha rule and the 409

Updating a file needs the blob sha of the version you are replacing. Get it from a read (GET .../contents/{path}) and send it back with the edit.

Two details cause most bugs:

  • Keep the new sha. A successful PUT returns the new file sha in content.sha. Store it in the editor's state. If you keep sending the old one, the second save fails.
  • Do not auto-retry a 409. It means the file (or the branch) changed since you read it, for example from a second tab or a direct commit. Fetching a fresh sha and resending would silently overwrite that change. Return the 409 and let the editor reload.

Browser side:

const res = await fetch("/api/admin/articles", {
  method: "PUT",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ slug, markdown, sha }),
});
if (res.status === 409) {
  // tell the editor to reload
} else if (res.ok) {
  const data = await res.json();
  sha = data.sha; // use this on the next save
}
Enter fullscreen mode Exit fullscreen mode

Pitfalls

  • Every save is a commit, and every commit can trigger a deploy. Keep draft: true in the front matter until the article is ready, and save less often.
  • UTF-8 and base64. Encode on the server with Buffer. In the browser, btoa throws on characters outside Latin-1, which includes Bangla text.
  • Write one file at a time. Parallel writes to the same branch can conflict. GitHub advises serial requests (as of October 2026, verify in the REST docs).
  • Branch protection. If the target branch requires pull requests, the direct commit is rejected. Commit to a branch that allows it.
  • Large files. The Contents API has size limits. Keep articles as text and store images elsewhere (as of October 2026, verify the current limits).
  • A missing sha on an existing file is rejected with a validation error, not a 409 (as of October 2026, verify the exact status code).
  • Protect the login route. Anyone who gets past the admin password can commit to your repo. Put a rate limit in front of it, as in Next.js rate limiting with Upstash Redis.
  • Not tested live. Everything above passed against mock servers only. Run one real commit to a scratch branch before you rely on it.

Summary

  • One PUT /repos/{owner}/{repo}/contents/{path} call turns a browser form into a commit, and the host redeploys.
  • New files need no sha. Updates need the current sha, and you must store the new one after each save.
  • Treat 409 as "someone changed this". Show it, do not retry blindly.
  • Keep the token server-side and scoped to one repo. Validate the slug before building the path.

Next: if you want a 6-digit code login for an admin or app instead of a password, here is the Supabase version. Supabase Email OTP in Next.js.


Originally published at najmulhasan.navicore.co/blog/github-contents-api-cms-in-next-js-publish-markdown-without-a-database.

Top comments (0)