Home / You Probably Don’t Need a CMS: Building a Lightweight Admin with Git and Astro

You Probably Don’t Need a CMS: Building a Lightweight Admin with Git and Astro

Olivier Carrère 18 min read
View as Markdown
On this page

What if a small organization didn’t need a CMS at all?

Not a lighter one, not a cheaper one. None.

A nonprofit or independent club’s website can easily run to a few hundred pages between history, policies, program pages, and years of past events. A conventional CMS treats all of that as equally editable, because that’s what a CMS is built to do. But watch how a small organization actually uses its own site and a different pattern shows up. Most of those pages are written once and barely touched again. A comparatively small slice of the content (events, prices, the weekly schedule, who’s teaching what) changes often enough that someone needs a real way to update it. That’s the part worth building an editing interface for. The rest isn’t.

So the useful question was never “how do we build a CMS.” It’s “what is the smallest editing interface this organization actually needs.” For a small climbing club whose site I build and maintain, the answer turned out to be a WordPress-shaped admin panel with no WordPress underneath it: no database, no plugin ecosystem, nothing to patch or subscribe to. Astro publishes the site. Git and GitHub hold the content. A small admin interface exists for exactly the data that changes often enough to be worth a form.

The idea, in one diagram

The whole system is one path, short enough to draw as a straight line:

  1. Non-technical editor
  2. Small admin interface
  3. API / GitHub
  4. Markdown / YAML in Git
  5. Vercel build
  6. Astro static website

Four things are doing four different jobs, and it’s worth naming them separately instead of lumping all of it under “CMS”:

Git

Source of truth
Every piece of structured content lives in a repository, with full history attached. It’s the version-control layer.

Astro

Publishing
Turns whatever is in Git into the static site a visitor actually gets.

YAML & Markdown

Content format
Plain, diffable, typed files, not rows in a database.

Admin interface

Editorial layer
A small, deliberately narrow application whose only job is making a subset of those files easy to edit, without anyone touching Git directly.

An editor never sees a commit, a branch, or a diff. They see a form. Git is still doing exactly what Git does underneath, for someone else’s benefit entirely.

Illustration of a Swiss Army knife with four blades labeled Git, Yaml, Astro, and Markdown, and the word CMS on its handle
The CMS Swiss Army Knife: Git, YAML, Astro, and Markdown.

The content that actually changes

Not every page on a small organization’s site earns the word “content” in the CMS sense. Some of it is closer to furniture: written once, correct for years, not worth an editing form. The rest is closer to inventory: it turns over, and someone needs to update it without opening a code editor.

The Vertical Horizon climbing club homepage with dashed overlays marking where each section's content comes from: the header, hero title, tagline, and footer address are labeled club.yaml; the upcoming sessions cards are labeled events.yaml; the member, session, and instructor counts are labeled members.yaml, events.yaml, and teachers.yaml; the social links are labeled social.yaml; and a purple-outlined editorial block, 'Why We Climb Together,' is labeled editorial/why-we-climb-together.mdx, with a legend distinguishing admin interface (YAML) content from static (MDX) content
The line drawn on the actual homepage: everything in blue comes from a YAML file editable through the admin panel, and the one purple block is hand-written MDX that nobody expects to touch this month.

Note. The club and the screenshots below, Vertical Horizon, are fictional. They are representative of nonprofits and independent organizations whose websites I’ve built and maintain, while keeping their actual data and branding confidential.

ContentHow often it changesEditing interface
Events & tripsOftenForm
Sessions & scheduleOftenForm
Membership & pricingOccasionallyForm
InstructorsOccasionallyForm
FAQOccasionallyForm
Social media linksOccasionallyForm
Location & access detailsRarelyForm
Treasury, members, documents, GDPR registerRarelyForm
Long-form pages (club history, approach to climbing)RarelyMarkdown/MDX
Site designVery rarelyCode

The website may hold a lot of content. That doesn’t mean all of it needs an editing interface. Long-form writing, the club’s history, its approach to climbing, anything that reads like a real page rather than a data record, stays in Markdown and MDX, edited directly in the repository by whoever is doing a substantial rewrite. Everything in the top half of that table gets a form. Everything in the bottom half doesn’t, and shouldn’t.

GitHub is the backend, not the editorial interface

Git already solves the storage problem. It stores structured text, keeps every version of it, and comes with an API, GitHub’s, for reading and writing files from outside a terminal. For a developer, that’s already a perfectly good content workflow:

$ vim events.yaml
$ git diff
$ git commit -m "Add outdoor day trip"
$ git push

For a volunteer running a climbing club, it isn’t a workflow at all. Expecting someone to install Git, understand branches, and write a commit message to add a Tuesday session isn’t a lightweight solution. It’s homework. What that person needs instead:

Event Editor Flow Diagram

Events

Add event

Title: Outdoor Day Trip
Date: 2026-10-12
Location: Fontainebleau
Price: 35 EUR

Save

Figure 1 — Event editor submission flow: admin form edits trigger automated validation, image optimization, and Git commit generation.

GitHub is an excellent developer interface. It’s not, on its own, an appropriate editorial interface. That gap, between “Git can store this” and “a volunteer can edit this,” is exactly what the admin interface fills, and it’s the only piece of this system that had to be built from scratch.

Under that form, saving is mechanical:

  1. An editor opens a page; the app fetches the current file through the GitHub Contents API.
  2. They make their edit and save.
  3. An API route writes the file back, and GitHub creates a commit.
  4. That commit triggers the site’s normal Vercel deployment.

There’s no second content store to keep in sync, and no copy of the content that can quietly drift from what’s on the site. It’s the same argument I’ve made before for keeping content in files instead of a database. The repository is the only copy, so it can’t disagree with itself.

One deliberate technical choice makes that diff readable. The save routine doesn’t take a JavaScript object and re-serialize the whole YAML file from scratch every time one field changes: it parses the existing document and touches only the nodes that actually changed. That matters because naive YAML serialization is noisy. Quoting shifts, formatting moves around, and a bare-looking date can silently turn into a different type than the schema expects (a publish date like "2026-09-08 10:00" has to stay a string, not become whatever a YAML parser’s default date handling decides it should be). Preserve the document’s structure and touch only what changed, and a one-field edit produces a one-line diff. An editor changes a title, and the commit looks like a title changed, not like the whole file got regenerated.

The architecture

Zoom out from any one save and the shape of the whole system is this:

Lightweight CMS Architecture Diagram

load / save

read

save

commit

branch / commit

deployment trigger

create event

event data + image

generated poster

commit poster

Admin UI
(browser)

Vercel API routes
GET · POST · DELETE

Parse / update
YAML & MDX

GitHub
Contents API

Git branch
draft or main

Vercel
build & deploy

Poster service
(image generation)

Figure 2 — Lightweight CMS architecture: browser editor and API endpoints integrate directly with GitHub repository storage and Vercel builds.

Five layers, five different jobs: the browser UI an editor touches, the Vercel API routes that authenticate and route the request, the YAML/MDX parsing step that applies a minimal edit, GitHub’s Contents API as the write path into the repository, and Vercel’s own build-and-deploy step that turns a new commit into a new deployment. There’s no CMS hiding in the middle of that diagram. There are a few API routes connecting systems that already had a reason to exist, plus one small poster-rendering service bolted on for a single feature, covered further down.

Git’s history is a genuine, useful side effect of this design, and it’s worth being precise about what it actually buys. Every save is a commit, so a price, an event date, or a paragraph changed by mistake is recoverable: the previous version is still sitting in the log. That’s real, and it’s a meaningfully better story than most CMS “undo” features. It isn’t, on its own, a backup strategy. Git history protects against accidental or bad edits inside the repository. It doesn’t protect against the repository itself being deleted, the hosting account being compromised, or the one place the encryption key lives (more on that in a moment) disappearing. Those still need an actual backup and access-control plan, same as with any other system. Version history and disaster recovery are related, not identical.

What editors can actually manage

None of this matters if the people who have to use it every week won’t. The volunteers running this club aren’t developers, and most of them have spent years inside an actual WordPress dashboard somewhere, so the admin interface borrows wp-admin’s visual language on purpose: compact list tables, familiar navigation, breadcrumbs, plain forms. Nobody has to learn a new mental model just because the underlying architecture changed underneath them.

Screenshot of the Summit Ridge Climbing Club administration dashboard. A dark sidebar lists sections for Dashboard, Analytics, Speed Insights, and Deployments, grouped under Activities (Events & Trips, Sessions & Schedule), Club (Instructors, Locations, Members, Documents), Website (Location & Access, Social Media, FAQ, Resources), and Administration (Membership & Pricing, Treasury & Accounts, Data Protection). The main panel shows a grid of sixteen module cards, including Analytics & Traffic, Deployments, Events & Trips, Treasury & Accounts, Sessions & Schedule, and Membership & Pricing, each with a short description, the YAML file it reads from, and a button to manage it.
The dashboard this post describes: a dark wp-admin-style shell fronting a grid of modules, each one a form over a single YAML or MDX file. No plugin screen, no update nag, no database health widget, just the parts of a CMS a climbing club actually uses.

Grouped by what they actually do, rather than where they sit in the sidebar, there are three kinds of module on that dashboard.

Content editors. The public-facing structured data from the table above: Events & Trips (events.yaml), Sessions & Schedule (schedule.yaml), Membership & Pricing (price.yaml), Location & Access (location.yaml), Instructors (teachers.yaml), Locations, the directory of partner gyms and outdoor crags (locations.yaml), FAQ (faq.yaml), Climbing Resources (publications.yaml), and Social Media (social.yaml). Each one follows the same shape: one card, one form, one YAML file committed straight back through the GitHub API described above.

Administrative and organizational data. Treasury & Accounts (treasury.yaml), Members (members.yaml), Internal Documents (bylaws, meeting minutes, insurance and venue agreements, kept in a private documents/ folder rather than a single file), and the Data Protection Register (data-processing-record.yaml, covered in the security section below). Same mechanism as the content editors, different audience: this is data the organization needs to track, not content the public site renders.

Read-only operational information. Analytics & Traffic and Deployments don’t touch the repository at all. They’re a thin, read-only window onto Vercel’s own APIs (Web Analytics and the deployment log), sitting on the same dashboard as everything that does write to Git, because that’s where an administrator would look for them, not because they’re the same kind of thing.

Screenshot of the Instructors module in the Summit Ridge Climbing Club admin. A search bar sits above a reorderable list of four instructors, Alex Morgan, Sophie Bennett, Daniel Brooks, and Emma Carter, each row showing a drag handle, position number, name, discipline, role badge, and location, with an Edit menu and a delete button. A public preview panel on the right shows Alex Morgan's instructor card: name, role and discipline badges, location, a short bio, and a note that the instructor is automatically included in course listings.
One module, up close: a compact, reorderable list table with a live preview beside it, the same pattern wp-admin has trained a generation of volunteers to expect.

From YAML to a live website

Everything above describes how content gets written. This is what happens on the other end.

This isn’t a mockup. The event below is being served from the same data and API pipeline this article has been describing, fetched live, at build time, on this page:

The Events & Trips module is a good one to see working end to end, because that path is public: events.yaml is read by a small companion Astro API, and the block below fetches it live from /api/events at build time, on this page, the same way any other consumer of that read-only endpoint would. The API it hits is a demo instance, set up specifically so this example has something real to fetch from, not an actual production system.

Live from the events API · fetched at build time

10:00 AM–12:30 PM Discovery Beginners welcome

Discovery Session & Top-Rope Intro

Vertical Hall, 12 rue des Alpinistes, 75000 Paris

An introductory rope session for newcomers. Learn how to fit a harness, tie a figure-eight knot, and belay safely on top-rope.

Led by Camille Morel €10 · Intro Session 4 / 12 spots left

Discovery Session & Top-Rope Intro

Date: Saturday, September 19, 2026 Time: 10:00 AM–12:30 PM Location: Vertical Hall, 12 rue des Alpinistes, 75000 Paris
Discovery Beginners welcome

An introductory rope session for newcomers. Learn how to fit a harness, tie a figure-eight knot, and belay safely on top-rope.

Led by: Camille Morel Price: €10 · Intro Session Spots: 4 / 12 spots left

Raw response from /api/events:

{
  "title": "Discovery Session & Top-Rope Intro",
  "date": "2026-09-19 10:00:00",
  "to": "2026-09-19 12:30:00",
  "type": "discovery",
  "level": "Beginners welcome",
  "capacity": 12,
  "remainingSpots": 4,
  "description": "An introductory rope session for newcomers. Learn how to fit a harness, tie a figure-eight knot, and belay safely on top-rope.",
  "location": "Vertical Hall, 12 rue des Alpinistes, 75000 Paris",
  "club": "Vertical Horizon",
  "price": 10,
  "priceLabel": "Intro Session",
  "teacher": [
    "Camille Morel"
  ]
}

That’s the whole round trip: a YAML file behind an admin form, served by a stateless API route, pulled into a static page at build time, with no database in between and nothing in this block hard-coded into the site. Switch to the “Raw JSON” tab above to see exactly what /api/events returned to build this block.

The point isn’t the JSON payload. It’s that this piece of structured content can be consumed independently of the website it was written for.

Why the API matters

A YAML file sitting in a repository is only reachable by whatever reads that repository. An API changes that. Once /api/events exists, anything that can make an HTTP request can read the club’s upcoming events without touching Git, GitHub credentials, or Astro’s build process at all.

This blog is one working example of that, not a hypothetical one. It’s a separate Astro project, with no access to the club’s repository, and the live block above proves the point: it fetches over HTTP from a public endpoint at build time, exactly like any other outside consumer would.

Other consumers aren’t built yet, but the API doesn’t need that to be true to be worth having. The same endpoint could, in principle, feed a calendar app, a mobile app, an internal dashboard, or a second website entirely, without any of them needing write access to the repository or knowledge of how the admin interface works. I’ve made a version of this argument before for structured content with more than one output. Once content is genuinely structured and reachable through an API, the number of things that can consume it stops being tied to the number of things that can edit it.

Automation: one dataset, multiple outputs

Separating structured content from presentation pays off in places that have nothing to do with the website itself. When an administrator creates a new event and picks an instructor and a photo, the admin API sends that data to a small poster-rendering service the club already runs. That service generates a poster, hands the image back, and the admin API commits it to the repository alongside the event data.

Screenshot of the event poster generator in the admin's event editor. A live A3 poster preview shows a fjord photograph with the title 'Outdoor Day Trip', a date, a QR code, and the venue address overlaid on it. Below the preview, poster adjustment controls for text position, text colour, and layout, followed by Download PDF (A3) and Download web image buttons.
The poster from the sequence diagram below, rendered live as the form is filled in: same photo, same event data, no design software involved.
CMS Automation Sequence DiagramVercel deployPoster serviceGitHubVercel APIEditorVercel deployPoster serviceGitHubVercel APIEditorCreate eventSend event data + photoGenerated posterCommit event + posterTrigger deploymentBuild Astro siteUpdated site is live
Figure 3 — Automation sequence: event creation triggers synchronous poster generation, repository commit, and production deployment.

A workflow that would normally mean creating the event in a CMS, opening a design app, finding the instructor’s photo, building the poster, exporting it, and attaching it back to the event collapses into one form submission. None of this makes Git a design tool. It’s the API argument from a different angle: once an event is one structured record instead of scattered across a CMS entry, a design file, and an export folder, small single-purpose services can plug into that record without the admin interface itself turning into a platform. Worth keeping that instinct in check, though. The moment automation like this starts justifying its own new CMS features, it has stopped being the smallest system that works.

Security and personal data

Three different things get called “content” on this site, and they don’t deserve the same treatment: public website content (pages, event descriptions, things any visitor already sees), structured operational content (prices, schedules, the treasury) that the organization needs but a visitor doesn’t, and personal data (names, emails, phone numbers collected on registration) that comes with actual legal obligations attached.

Removing the database doesn’t remove the need for security on the first two categories. It narrows what has to be secured. The admin routes check the authenticated session on every request and guard state-changing operations against forged requests, and the GitHub credential the server uses stays server-side, scoped to the one repository it needs to touch. There’s no public database holding every user and every piece of content, and no plugin ecosystem’s worth of independently maintained extensions to worry about: one small application, whose entire job is letting an authenticated administrator change specific files in a repository, is a considerably smaller thing to reason about than a WordPress install’s user table and its accumulated plugin permissions.

Personal data gets stricter treatment, because storing it in Git at all is a real design decision, not a default. Article 30 of the GDPR requires a register of processing activities: for every purpose an organization processes personal data for, someone has to record the legal basis, who has access, how long the data is kept, and what protects it. The Data Protection Register module is that register, one YAML record per activity, seven of them here (member management, event registrations, communications, the mailing list, payments and accounting, volunteer coordination, and the website’s own contact form), each carrying the fields a lawyer would ask for, as structured data instead of a paragraph nobody rereads until an audit forces the question. The controller’s own name, legal form, and address are read-only in that register, synced from the Location & Access module that already holds them, so there’s only one place that address can be wrong instead of two.

Actual registration data gets the strongest treatment. An event registration collects a name, an email, sometimes a phone number, and it lives in the same Git repository as everything else, but never in the clear: participant lists are encrypted with AES-256-GCM before they’re committed, with a fresh random IV on every write, so the file that reaches GitHub holds a version number, an algorithm name, and a block of ciphertext, nothing else. The encryption key lives in an environment variable, never in the repository. A volunteer with read access to the whole commit history, which on a small team is most of them, can browse every event the club has ever run without being able to read who signed up for any of them. This is what actually lands in Git when someone registers, the whole diff:

diff --git a/people.yaml.enc b/people.yaml.enc
new file mode 100644
index 00000000..cd088b04
--- /dev/null
+++ b/people.yaml.enc
@@ -0,0 +1,4 @@
+version: 1
+algorithm: AES-256-GCM
+iv: lH4vXaZvZbqgUn9F
+ciphertext: Qc2ZMJw9ADJEaSdjnKjbKIWihYw4+8mxZxPuVVujRi+FAAhbFjtzUxChEqHp4oTlCMKmV/9DeN5ui9NPsjEb7eh3odnwruiZsO7PPj5U8pBnI7VxwDXvwMioXqHyyqM6jaTV89okeOlb8EkxXtA8qJge/GMBmuN7qB7YjBrhy5YoAlqXmjITZOfPXjvjpPhFsAlcRJEupaQYnZGBfC4/5vXQ3CnOiqVGOMbYPsAN2AZOwf4ym+hxQyR5RtSrMfK6NpTU0Tjz/rNlRAdfjItN0RNJfclaXKj6fzKDeTVya3aNjpxfs6u4

Four lines, and every one of them is either metadata or ciphertext. No reviewer, and no attacker with repository access, ever sees a name or an email in the history.

These measures address real GDPR concerns: a documented legal basis, minimized and time-bound retention, encryption of the personal data that’s actually sensitive. They don’t, by themselves, establish that the whole system is GDPR compliant. Compliance also depends on things this article can’t verify from the outside, like the organization’s actual retention practice, its data-processing agreements, and how the encryption key itself is stored and rotated. Treat this as a reasonably well-designed technical foundation, not a compliance certificate.

Where this approach stops working

This isn’t a CMS for every organization, and it would be a mistake to read it that way. The Git-plus-Astro approach starts making less sense as soon as an organization needs things this design was never trying to provide:

  • many editors working at once, with real permission tiers between them
  • formal editorial workflows: drafts, review, scheduled publishing, sign-off
  • real-time collaboration on the same document
  • a large media library with proper asset management
  • extensive localization workflows across many languages
  • large-scale relational content, where records reference and depend on each other in complex ways
  • several applications sharing one content platform, each with different needs
  • sophisticated publishing workflows generally
  • editors who need to manage content, including the schema itself, completely independently of a developer

None of that makes WordPress or a headless CMS the wrong choice for those organizations. It makes them the right tool for a different, larger problem than the one this article is solving.

When to choose WordPress or a headless CMS

Put next to the two obvious alternatives, the trade-offs are less about which system is “better” and more about which problem each one is actually built for. This is a qualitative comparison, not a benchmark: your mileage depends heavily on implementation, team, and scale.

RequirementGit + Astro adminWordPressHeadless CMS
Small organizationExcellentGoodOften excessive
Structured contentExcellentGoodExcellent
Long-form contentExcellentExcellentExcellent
Simple editorial UIGoodExcellentExcellent
Many editorsLimitedExcellentExcellent
Editorial workflowsLimitedGood to excellentExcellent
Multiple frontendsLimitedLimited to goodExcellent
Complex relationshipsLimitedGoodExcellent
Git-based historyExcellentLimitedDepends
Minimal infrastructureExcellentModerateModerate to high
No CMS subscriptionExcellentExcellentOften low-cost or free tiers available

WordPress earns its “Excellent” marks for a real reason: it solved editorial UI and multi-editor workflows a long time ago, and it still does that well. What it drags in to do it, PHP, a database, an admin application, user accounts, updates, and a plugin ecosystem, is a reasonable trade for an organization that actually uses that surface area. For one that mostly needs to change a handful of prices and add a Tuesday session, that surface area is mostly unused weight. A headless CMS makes a similar trade at a different scale: real value once several applications share one content platform or many editors need real workflows and permissions, and otherwise another system sitting between the editor and the site Astro already knows how to build.

The smallest system that works

The lesson here isn’t that every website should use Git as its CMS. Most shouldn’t, and the comparison table above says exactly where that line sits.

It’s that content architecture and editing interfaces don’t have to be the same thing. Git can be an excellent content store without being the interface anyone actually edits through. Astro can be an excellent publishing system without being a CMS. And a small organization can get a CMS-like editing experience (forms, familiar navigation, a save button) without installing a conventional CMS to get there.

What surprised me, building this, wasn’t the architecture. It was how much of it was already sitting there unused. The repository was already the source of truth. GitHub already had an API. Astro already turned files into pages, and Vercel already deployed the result. The only piece actually missing was a form a volunteer could use without knowing what a commit is. Once that piece existed, everything else the club needed was already true. They just didn’t know it yet, because nobody had asked the smaller question first.

Follow Olivier Carrère on LinkedIn

Continuous writing on docs-as-code, DITA XML, YAML, and AI-assisted documentation pipelines.

Follow ↗