Adding a Git-Based CMS to a Static Astro Site
Everything on hosch.us started as hand-edited MDX and hardcoded TypeScript. Writing a post meant opening a file in an editor, committing, and pushing. That’s fine for someone who lives in a terminal, but it was real friction against actually publishing, and it meant the “content” side of the site — bio, work history, stats, the home page hero — wasn’t content at all. It was code. This is the story of closing that gap: first for blog posts and projects, then for literally every text-bearing section on the site.
Why not a hosted CMS
The obvious options were Sanity, Contentful, or something similar — a hosted backend with its own auth and its own database, fetched at build time. I didn’t want that. The whole point of this site is that it’s a static Astro build with no server-side moving parts beyond Vercel’s static hosting. Adding a hosted CMS means a new external dependency, a new account, a new API to keep in sync with Zod schemas that already live in the repo.
The alternative is a git-based CMS: an admin UI that reads and writes the same MDX/YAML files already in the repository, authenticated against GitHub, with every edit landing as a real commit. I picked Sveltia CMS — a from-scratch, actively maintained rewrite of Netlify/Decap CMS’s config format, vendored same-origin under public/admin/ rather than pulled from a CDN. Because it’s a static file mount (index.html + config.yml), not an Astro route, it’s structurally invisible to @astrojs/sitemap and getStaticPaths() — no special-casing needed to keep it out of the sitemap.
The one crack in output: 'static'
Every route on this site is prerendered. That’s a hard constraint, and it’s incompatible with GitHub OAuth on its face — OAuth needs a server to exchange an authorization code for an access token, and a purely static site has no server to run that exchange.
The fix was to accept exactly one exception: swap the static adapter for @astrojs/vercel, and mark exactly two routes export const prerender = false — /api/auth (kicks off the GitHub OAuth redirect) and /api/auth-callback (handles the redirect back, exchanges the code, hands Sveltia a token). Every other route in the project stays fully static. I wrote this into CLAUDE.md as an explicit rule with a warning attached: adding a third on-demand route is its own architectural decision, not something to reach for casually just because two already exist.
Config paths are relative to the wrong root
This one cost real debugging time. The Astro project doesn’t live at the repository root — it’s nested under Github/hoschdb/. Sveltia’s config.yml doesn’t know or care about that; every folder:, media_folder:, and file-collection file: path has to carry the full Github/hoschdb/ prefix, resolved from the repo root, not the Astro project root.
There’s exactly one exception: public_folder:, which is a site-relative URL (/uploads) the browser resolves against the deployed site, not a filesystem path — it must not carry the prefix. Getting this backwards is deceptively easy because it fails quietly: the CMS UI loads fine, only file writes go to the wrong place. Two separate research passes got this backwards before the live CMS caught it on an actual save attempt.
A toolbar that silently disabled itself
The best bug from this whole project. Sveltia’s richtext widget takes a modes: array, and the two valid values are rich_text and raw — underscored. Every reference I’d generated for this cited rich-text and plain-text — hyphenated — because those are Sveltia’s internal display labels for the modes, shown in its own UI toggle. They are not valid config input.
Neither hyphenated string matched a real enum member, so Sveltia didn’t error. It just silently fell back to raw mode with the entire formatting toolbar disabled — no bold, no headings, no links, nothing in the console to explain why. The wrong config shipped for a full plan cycle before it was caught, and only because someone doing a live editorial pass noticed the toolbar was just… missing, then went and read node_modules/@sveltia/cms/dist/sveltia-cms.js directly instead of trusting either research doc a second time. Lesson: when a config value has both a “real” form and a “friendly display” form, and you only have written docs to go on, verify against the actual bundled source before shipping.
Fields vs field, and why the difference matters
Sveltia (like Decap before it) distinguishes plural fields: from singular field: in list widgets, and the difference is not cosmetic:
field:— a list of scalars. Each list item is a single value of one type.fields:— a list of objects. Each list item has its own sub-fields.
Pick the wrong one and the YAML shape doesn’t match what a Zod schema expects — not with a helpful error, just a silent parse rejection. There was no prior precedent for this distinction anywhere in the codebase, so it only got proven correct by an actual round trip through the live /admin UI: configure the field, save through the CMS, check what actually landed in the YAML file. That round trip happened twice — once for the stats widget (a list of { value, label } objects, so fields:), and later reused for work history entries.
Making the preview pane tell the truth
Sveltia ships a live preview pane next to the editor, but by default it renders with no site styling at all — just browser defaults. That’s actively misleading for an editor deciding how a change will look. The fix was a small build step: concatenate tokens.css + hoschdb.css + a tracked preview shim into public/admin/preview.css on every build, then register that stylesheet into Sveltia’s preview iframe via a same-origin classic script. The wrapper selector Sveltia’s preview actually renders into had to be captured from live devtools, not guessed from docs, and wired into the shim to mirror the real site’s .hdb-prose rules.
The same pipeline got reused later for the CMS’s structural site-content fields (hero, bio, stats, work, skills, contact) once those moved into the CMS too — same shim, same concatenation step, extended rather than duplicated.
From two collections to everything
The first CMS pass only covered blog posts and projects — real content, but a fraction of the site’s actual text. The hero headline, the “now” card, the bio, the skills list, the contact block: all of that was still hardcoded TypeScript in src/data/site.ts.
Closing that gap needed a pattern Astro’s Content Collections don’t provide out of the box: one object with several independently-shaped keyed sections, rather than a folder of independent entries. The solution was a file() loader over a single site.yaml, validated by one z.union([...]) of seven per-section Zod schemas, accessed through a typed getSiteSection(id) helper. The union matters for more than validation: it turns Zod’s opaque invalid_union build error into one that actually names the offending section, instead of leaving you guessing which of seven possible shapes failed.
All seven schemas got written up front, before most of the sections were actually wired into the CMS config — which meant later work never had to reopen the schema module to add a field, and CMS-config-to-Zod-schema field-name parity was checkable generically (walk both structures, diff the keys) rather than one hardcoded assertion per field.
Tags as a managed vocabulary, not free text
One smaller but genuinely useful decision: blog tags aren’t a free-text field in the CMS. They’re a relation widget pointed at a small tags.yml vocabulary file, itself a CMS-editable file collection. That means every tag in use is a deliberate, visible choice from a shared list rather than whatever string an editor happens to type — no postgres next to Postgres next to postgresql silently fragmenting the tag archive pages three posts later.
Proving it, not just building it
None of this is worth much without something that actually checks it. Each phase shipped with a verification script that parses the real content.config.ts Zod schemas directly — not a hardcoded copy of what the schema is supposed to look like — so it can’t quietly drift out of sync with the code it’s checking. Every new check in that script was demonstrated failing first, by deliberately reverting the fix it was meant to catch, before being trusted as a real gate. A passing check that has never been shown to fail hasn’t actually proven it catches anything.
The result: everything text-bearing on hosch.us — posts, projects, and every structural content section — is now editable from a browser at an unlisted, GitHub-OAuth-gated /admin, backed by real Zod validation and a live preview that matches production, while every route that isn’t the OAuth handshake itself stays exactly as static as it was on day one.