MDX MDX MDX!
Hey there! So picture this: I wanted a CMS for this blog. A nice admin UI, a place to write, the whole deal. And then I sat down to actually build it and realized the "normal" path meant spinning up a database. A database. To hold, what, a dozen posts? Then hosting it. Then keeping it alive. Then paying for it. All that machinery, humming away forever, to babysit some markdown.
Yeah, no. That felt like wheeling in a forklift to carry a sandwich.
So I went looking for something lighter, and I came across Keystatic. A few evenings later, here we are, and here's the fun secret: this blog has no database at all.
The posts you're reading are just MDX files sitting in the repo. I edit them through an admin UI that writes straight back to GitHub as commits. That's Keystatic running in its GitHub storage mode, and wiring up the auth inside a monorepo had a couple of sharp edges that I figured were worth writing down.
So, let us dive in!
Here's the shape of the whole thing. There are two completely separate paths:
- A public site that reads content from committed files.
- An admin app at
/keystaticthat writes content by talking to the GitHub API on your behalf.
Keep those two in your head as we go, because auth only ever concerns the writing one.
Why GitHub mode?
Keystatic gives you three storage kinds to pick from:
local— writes to your filesystem (great for tinkering).cloud— 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.
I went with github, and it's exactly the no-database escape hatch I was hoping for. No separate hosting, nothing to keep alive, and the best part: content review happens in the exact same place as code review. For a personal site, that's the sweet spot.
export default config({
storage: {
kind: "github",
repo: "xenomech/space",
pathPrefix: "apps/blog",
branchPrefix: "blg/",
},
// …collections, singletons
});Beyond the obvious repo, two fields here are doing the heavy lifting:
pathPrefix: "apps/blog"— this is a Turborepo monorepo, so the Keystatic project doesn't live at the repo root. The collection paths in my config are written relative (content/posts/*), andpathPrefixis what tells the GitHub storage where that root actually sits inside the repo. Leave it out, and Keystatic goes hunting forcontent/posts/*at the repo root and finds a whole lot of nothing.branchPrefix: "blg/"— every edit lands on a branch namedblg/…and opens a PR instead of committing straight tomain. So content gets the same gate as code: nothing sneaks into production unreviewed.
The two wiring files
Here's the part I love, Keystatic is mostly its own little SPA plus a route handler. In the App Router, that comes down to two tiny files.
First, the admin UI. It's a client page that mounts Keystatic's app and takes over the /keystatic/* subtree:
// app/keystatic/[[...params]]/page.tsx
"use client";
import { makePage } from "@keystatic/next/ui/app";
import keystaticConfig from "../../../../keystatic.config";
export default makePage(keystaticConfig);And then the API handler, the bit that does the actual work: the OAuth callback, the token exchange, and proxying reads and writes over to GitHub:
// 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 });And that's the whole integration surface! Everything else is just the GitHub App and four environment variables. Still no database in sight.
Setting up the GitHub App
First things first: GitHub mode authenticates through a GitHub App, not a classic OAuth app, and not a personal access token. That distinction is worth holding onto, a GitHub App has fine-grained, per-repo permissions and acts on behalf of the signed-in user, so each editor commits as themselves.
I set mine up by hand over in Settings → Developer settings → GitHub Apps → New. Most of that form is boilerplate you can breeze through, but a handful of fields are the ones that actually wire into Keystatic. Let's walk through those.
The callback URL. This is the one that matters most. When an editor signs in, GitHub finishes the OAuth handshake by bouncing them back to this URL:
https://your-site.com/api/keystatic/github/oauth/callbackAnd here's the satisfying part, that path isn't arbitrary. It lands right back on the route handler from earlier (app/api/keystatic/[...params]/route.ts), which is exactly where makeRouteHandler is sitting and waiting to do the token exchange. The two halves click together. I also add the localhost flavour so local dev works too (GitHub Apps happily accept more than one callback URL):
http://127.0.0.1:3000/api/keystatic/github/oauth/callbackWebhooks. Keystatic doesn't use them, so I unchecked Active and moved on. One less thing humming in the background.
Permissions. Three, and only three:
- Contents — read & write (the actual file edits)
- Pull requests — read & write (so
branchPrefixcan open those PRs) - Metadata — read-only (the required baseline)
Hit save, and GitHub hands back the three values our env vars are hungry for:
- The App name becomes the slug in its URL →
NEXT_PUBLIC_KEYSTATIC_GITHUB_APP_SLUG - The Client ID, printed right there on the app's page →
KEYSTATIC_GITHUB_CLIENT_ID - A client secret, which you mint yourself with Generate a new client secret →
KEYSTATIC_GITHUB_CLIENT_SECRET(copy it the moment it appears, GitHub only shows it once)
One last step: the app still needs to be installed on the repo (GitHub → the app's page → Install). Creating the app and installing it are two separate actions, but Keystatic has your back here, if it isn't installed yet, it'll automatically walk you through the install flow the first time you try to save. No mysterious 404 to debug.
Four environment variables
Creating the app hands you three of these. The fourth, you generate yourself:
# 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`Notice the slug is NEXT_PUBLIC_. That's because the client UI needs it, it links out to github.com/apps/<slug> so an editor can go install the app. The client id, secret, and the signing secret, on the other hand, are server-only and never reach the browser.
I also like to validate these at boot with a shared @space/env package (t3-env + Zod), so a missing variable fails the build loudly instead of handing me a mysterious 500 at runtime:
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
});And next.config.ts does a bare import "@space/env" so this validation runs before anything else gets a chance to.
Reading is a different path entirely
I want to be really explicit here, because conflating these two is so easy to do. None of the stuff above touches how the site actually gets its content. That job belongs to the reader, pointed at the local checkout:
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, and (you guessed it) no database. The GitHub App only ever exists for the editing round-trip. So even if every single credential were wrong, the public site would still render perfectly, only /keystatic would refuse to save. I find that pretty reassuring.
The loop
Put it all together, and editing this blog looks like this: I 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, and the reader picks up the new file.
A CMS, with no database to spin up, host, or babysit, where every change is a reviewable diff. Turns out I didn't need the forklift after all.
Well, that's a wrap, folks! Hope you enjoyed reading.
More fun stuff awaits. Happy hacking!