A CMS for Astro that keeps the site static
How to let a client publish on an Astro site without switching to server rendering, without handing over the repository, and without the CMS becoming a single point of failure.
An Astro site that ships as static output deploys to a CDN, costs almost nothing to host and absorbs a traffic spike without anyone doing anything. Then the client wants to change a price, and the whole thing gets awkward: either you hand them the Git repository, or you switch the site to server rendering, or you get the “can you change this for me?” email for the third time this month.
None of those three is a good answer. Here is what we settled on.
Why the usual options get stuck
Git-based (Decap, Tina). The editor commits to the repository. Technically elegant, but the client inherits a GitHub account, a workflow they do not understand, and any merge mistake becomes their problem. More importantly, content and code share one history, so restoring a piece of content means touching the repository.
Classic headless (Strapi, Sanity, Prismic). Good products, but the paid floor sits between $10 and $18 per month per project. Across twenty brochure sites billed at 40 € of monthly maintenance, the margin is gone. Extra locales are almost always another pricing tier.
Server rendering. You lose the exact thing Astro was picked for: the site becomes an application to watch, with a response time, a cache to reason about and a hosting bill that follows traffic.
What we do instead
The principle fits in one sentence: the CMS never serves the site, it serves the build.
- The editor works in a web interface, on their phone if they want to.
- When they hit Publish, the API freezes the content into an immutable JSON file: a snapshot, pushed to a CDN.
- The API then triggers the build at the site’s host (Vercel, Netlify, Cloudflare, GitHub Actions or your own server).
- The build reads the snapshot and produces the static site, as usual.
Content therefore travels from the CMS to the site at build time, never on a visitor’s request. The finished site has no dependency on the CMS: it does not call it, does not know about it, and does not need it to be running.
The consequence that matters
If the CMS goes down, published sites stay up. They are static, they are on a CDN, there is nothing to go down.
Better: because the snapshot lives on a CDN rather than inside the API, a build started during a CMS outage still succeeds. We test this deliberately, by switching the API off and checking that a deployment still goes through.
That is the difference between a CMS the site depends on, and a CMS the site draws from.
For developers: the schema stays in code
The content model is declared in a TypeScript file in the project, not clicked together in a UI:
import { collection, defineConfig, fields } from '@menestrel/fields';
export default defineConfig({
project: 'atelier-morel',
locales: { default: 'fr', others: ['en'] },
collections: {
services: collection({
label: { fr: 'Prestations', en: 'Services' },
slugFrom: 'title',
fields: {
title: fields.text({ required: true, localized: true }),
summary: fields.textarea({ localized: true }),
price: fields.number({ unit: '€' }),
photo: fields.image(),
},
}),
},
});
One command syncs that schema to the server, which generates the matching editing forms. The schema therefore lives in code review, in branches and in Git history, like the rest of the project. What does not live there is the content: a client’s copy has no business in a repository.
On the rendering side, content arrives through a standard Content Layer loader, so getCollection() and typing behave exactly as with any other Astro source.
What the snapshot actually is
The word “snapshot” is doing a lot of work above, so here is what it means concretely.
When an editor publishes, the API serialises the current published state of every entry in the project into a single JSON document, gives it an identifier that never changes, and uploads it to object storage behind a CDN. Nothing about it is mutable afterwards. A later publication produces a new snapshot with a new identifier; the old one stays exactly as it was until it expires.
Three properties follow, and they are the reason the architecture is shaped this way.
A build corresponds to exactly one content state. Without this, a build that starts at 14:31 and finishes at 14:33 might contain a change made at 14:32, or might not, depending on caching and timing. That class of bug is miserable to diagnose because everything reports success.
Rolling back is selecting, not reconstructing. Every publication is already a restorable point. Going back to this morning is pointing a build at an earlier identifier, not editing the content back by hand from memory.
The CMS is not in the path. The build fetches a static file from a CDN. Our API can be down, under maintenance, or having its worst afternoon, and a client’s deployment still completes. We verify this deliberately rather than assuming it: switch the API off, trigger a build, confirm it goes green.
The cost is storage, which is why snapshots expire: seven days on the free plan, ninety days on Site, a year on Studio. Beyond that window, rolling back means editing by hand again.
Where the schema lives, and where it does not
One more distinction that shapes everything: the model is code, the content is not.
The content model is a technical decision with consequences for your templates. It belongs in your repository, in code review, in branches, in history. A pull request that adds a field and the component consuming it is one coherent change.
The client’s words are not a technical decision. They change on the client’s schedule, they are written by people who will never see a repository, and their history should read as a list of publications rather than as commits interleaved with your refactoring. Restoring last month’s prices should not mean archaeology through a fortnight of your own work.
There is a practical argument too, which is photographs. Content in a repository means images in a repository, and a brochure site can accumulate several hundred megabytes of dead image data in a year, downloaded by every clone, forever.
The obligation this creates is an export that genuinely works. Ours is one command, on every plan including the free one, producing markdown and JSON in content-collections format: the same shape you would have had with flat files.
What it does not do
For the sake of honesty, the limits of the model:
- Publishing is not instant. There is a build between the click and the live page, usually one to three minutes. We own that and we show it: the editor watches the progress and knows when the change is live.
- It is not built for content that changes every minute. A rolling news site wants server rendering, not this.
- It is not a page builder. The editor fills fields defined by the developer; they do not drag blocks around a grid.
Those three limits are choices, not missing features. They are what keeps the site static, the hosting at a few cents and the mental model simple.
What this means for your Astro code
Nothing, which is the point worth making explicitly.
Content arrives through a standard Content Layer loader, so getCollection() returns typed entries and render() renders them. Your page components do not know or care where the content came from. Swapping a folder of markdown for a CMS is a change in content.config.ts and nothing else:
import { defineCollection } from 'astro:content';
import { menestrelLoader } from '@menestrel/astro';
export const collections = {
services: defineCollection({ loader: menestrelLoader({ collection: 'services' }) }),
};
The loader derives the schema from your content model, so you do not restate it in Zod and the two cannot drift apart.
Images are the one place templates usually change, and for the better: instead of a path you get an object carrying dimensions, alt text and a focal point, which is what you need to avoid layout shift and to crop sensibly on mobile.
Trying it
The documentation covers installation, and the starter puts a project on its feet in one command:
npm create menestrel@latest
The free plan covers a full site, two languages included, with no card.
If you already have an Astro site with local markdown collections, menestrel import reads them directly, images included, and builds the content model from what it finds. On a brochure site that is usually under thirty minutes.
And if you would rather see how this compares with the tool you already use, the comparisons go through Contentful, Strapi, Sanity, Storyblok, Directus and the git-based family one at a time, each with the cases where they are the better choice.