Posts

Code

24 Jun 2026

5 MIN read

••• views

A Git-based CMS: Keystatic with GitHub auth

Wiring Keystatic into this Next.js monorepo so edits become GitHub commits

KeystaticNext.jsGitHub

This blog has no database. The posts you're reading are MDX files in the repo, and I edit them through an admin UI that writes straight back to GitHub as commits. That's Keystatic in its GitHub storage mode, and getting the auth wired in a monorepo had a couple of sharp edges worth writing down.

The shape of it: a public site that reads content from committed files, and an admin app at /keystatic that writes content by talking to the GitHub API on your behalf. Those are two separate paths. Auth only concerns the writing one.

Why GitHub mode

Keystatic has three storage kinds. local writes to your filesystem. cloud is their hosted offering. github keeps content as files in your repo and turns every edit into a commit or, with a branch prefix, a pull request. No database, no separate hosting, and content review happens in the same place as code review. For a personal site that's the sweet spot.

ts
export default config({
  storage: {
    kind: "github",
    repo: "xenomech/space",
    pathPrefix: "apps/blog",
    branchPrefix: "blg/",
  },
  // …collections, singletons
});

Two fields matter beyond the obvious repo:

  • pathPrefix: "apps/blog" — this is a Turborepo monorepo, so the Keystatic project doesn't live at the repo root. Collection paths in the config are written relative (content/posts/*), and pathPrefix tells the GitHub storage where that root sits inside the repo. Without it, Keystatic would try to read and write content/posts/* at the repo root and find nothing.
  • branchPrefix: "blg/" — every edit lands on a branch named blg/… and opens a PR instead of committing to main. Content gets the same gate as code: nothing hits production unreviewed.

The two wiring files

Keystatic is mostly its own SPA plus a route handler. In the App Router that's two tiny files.

The admin UI — a client page that mounts Keystatic's app and takes over the /keystatic/* subtree:

tsx
// app/keystatic/[[...params]]/page.tsx
"use client";
 
import { makePage } from "@keystatic/next/ui/app";
import keystaticConfig from "../../../../keystatic.config";
 
export default makePage(keystaticConfig);

And the API handler that does the actual work — the OAuth callback, the token exchange, proxying reads/writes to GitHub:

ts
// app/api/keystatic/[...params]/route.ts
import { makeRouteHandler } from "@keystatic/next/route-handler";
import keystaticConfig from "../../../../../keystatic.config";
 
export const { GET, POST } = makeRouteHandler({ config: keystaticConfig });

That's the whole integration surface. Everything else is the GitHub App and four environment variables.

The GitHub App

GitHub mode authenticates through a GitHub App — not a classic OAuth app, not a personal access token. The difference matters: a GitHub App has fine-grained, per-repo permissions and acts on behalf of the signed-in user, so each editor commits as themselves.

You don't hand-craft it. Run the app, visit /keystatic with no credentials configured, and Keystatic detects that and walks you through. It pre-fills the permissions the CMS needs:

  • Contents — read & write (the actual file edits)
  • Pull requests — read & write (so branchPrefix can open PRs)
  • Metadata — read-only (required baseline)

GitHub creates the app, redirects back, and hands you the credentials to paste into .env.

After the app exists, install it on the repo (GitHub → the app's page → Install). Creating the app and installing it are two separate steps: auth will succeed but every write 404s if the app isn't installed on the repo it's trying to edit.

Four environment variables

That flow produces three of these; the fourth you generate yourself:

bash
# from the GitHub App
KEYSTATIC_GITHUB_CLIENT_ID=...
KEYSTATIC_GITHUB_CLIENT_SECRET=...
NEXT_PUBLIC_KEYSTATIC_GITHUB_APP_SLUG=...
 
# you generate this — signs the Keystatic session
KEYSTATIC_SECRET=...   # e.g. `openssl rand -hex 32`

The slug is NEXT_PUBLIC_ because the client UI needs it — it links to github.com/apps/<slug> so an editor can install the app. The client id, secret, and the signing secret are server-only and never reach the browser.

I validate them at boot with a shared @space/env package (t3-env + Zod), so a missing variable fails the build loudly instead of producing a mysterious 500 at runtime:

ts
export const env = createEnv({
  server: {
    KEYSTATIC_GITHUB_CLIENT_ID: z.string().min(1),
    KEYSTATIC_GITHUB_CLIENT_SECRET: z.string().min(1),
    KEYSTATIC_SECRET: z.string().min(1),
    // …
  },
  client: {
    NEXT_PUBLIC_KEYSTATIC_GITHUB_APP_SLUG: z.string().min(1),
    // …
  },
  // …runtimeEnv
});

next.config.ts does a bare import "@space/env" so this runs before anything else.

Reading is a different path entirely

Worth being explicit, because conflating the two is easy: none of the above touches how the site gets its content. That's the reader, pointed at the local checkout:

ts
import { createReader } from "@keystatic/core/reader";
import keystaticConfig from "../keystatic.config";
 
export const reader = createReader(process.cwd(), keystaticConfig);

At build and request time the site reads the committed MDX and YAML straight off disk, no GitHub API, no auth, no tokens. The GitHub App only exists for the editing round-trip. So even if every credential were wrong, the public site would still render perfectly; only /keystatic would refuse to save.

The loop

Put together, editing this blog looks like: open /keystatic, sign in with GitHub, the app confirms I can write to xenomech/space, I edit a post, Keystatic commits it to a blg/… branch and opens a PR, I merge, Vercel rebuilds, the reader picks up the new file. A CMS, with no database, where every change is a reviewable diff.

Keep reading