A marketing site with no CMS: MDX, git and a build pipeline
No database, no admin panel, no plugin updates. Content is MDX in the repo, frontmatter is a Zod schema that fails the build, review is a pull request — and publishing is a merge. The architecture behind this website, and the case where you shouldn't copy it.
Every agency website quote we've ever seen includes a CMS, and nobody asks why. It's the default the way a request-time server is the default: not chosen, just inherited. So you end up with a database, an admin panel behind a login on the public internet, a plugin surface that needs patching, and a content model that exists in two places — the editor's idea of it, and whatever the templates actually read.
This site has none of that. Content is MDX committed to the repo, validated at build time, reviewed in pull requests, and published by merging. It is the first architecture decision record in the repo — written on day one rather than discovered later — because the interesting part of this choice isn't the tech — it's being honest about who's going to be typing.
A CMS is a feature for people who can't use git
That's the whole product. Strip away the marketing and a CMS exists so that someone who will never open a terminal can change the words on a page without asking a developer. It is a genuinely valuable feature, and when a client has a marketing team, it's the right call — it's why we still build WordPress and WooCommerce sites for the clients who need one, and why "just use MDX" is not advice we give unconditionally.
But if the primary author is a developer — which, on our own site, it is — you're paying the full cost of that feature while using none of it. And the costs don't scale down when the usage does:
- A database on the request path. Every page view becomes a query, or a cache in front of a query, which is a second thing that can be wrong.
- An authenticated admin panel exposed to the internet, which is now the most attractive target on your domain.
- A plugin surface. Third-party code, running with database access, updated on someone else's schedule.
- A content model that can't be type-checked. The template reads
post.subtitle; nobody notices it's empty on eleven posts until someone scrolls.
The trade we made in return is one line, and it's real: only people who can use git can publish. We wrote that into the ADR under "Consequences" instead of pretending it away, because it's the thing that would make this architecture wrong for a different team.
Frontmatter is a schema, and it fails the build
The part that makes MDX-in-git feel like a content system rather than a pile of files is that the frontmatter is typed. Every Post, Project, Video and Job in this repo is parsed at build time by content-collections against a Zod schema:
const posts = defineCollection({
name: "posts",
directory: "content/posts",
include: "**/*.mdx",
schema: z.object({
title: z.string().min(1),
summary: z.string().min(1),
date: z.coerce.date(),
tags: z.array(z.string()).default([]),
cover: z.string().optional(),
/** Featured entries pin to the top of the listing. */
featured: z.boolean().default(false),
/** Set to false to keep the entry hidden from the website. */
published: z.boolean().default(true),
}),
})Get it wrong and you don't get a broken page, you get a failed build:
Validation failed on content/posts/marketing-site-without-a-cms.mdx:
- summary: Invalid input: expected string, received undefined
- tags: Invalid input: expected array, received stringThat's the actual output from writing tags: "mdx" instead of tags: ["mdx"] and forgetting the summary — exit code 1, no artifact, nothing deployed. Compare the CMS equivalent: a required field that was added to the model six months after the first fifty entries were written, so it's optional in practice, so the template has a ?? in it, so the page renders with a hole in it and stays that way until someone complains.
Because pnpm typecheck runs content-collections build before tsc, and the pre-commit hook runs typecheck, a malformed post is usually caught on the machine that wrote it. The next line of defence is CI, which runs lint → typecheck → build on every pull request. There is no line of defence after that, and there doesn't need to be.
The unit of publishing is a pull request
A post is one MDX file and, if it has a cover, one SVG. That means a new article is a diff — reviewable in exactly the way code is reviewable, by the same people, in the same tool, with the same history.
apps/web/content/posts/marketing-site-without-a-cms.mdx | 108 ++++++++++
apps/web/public/covers/marketing-site-without-a-cms.svg | 109 ++++++++++Label the PR preview and our Woodpecker pipeline builds an image and comments back with a URL, so the draft gets read on a real instance of the site — right typography, right code-block theme, right cover crop — before anyone approves it. Then publishing is a merge, and the deploy that follows is the same deploy that ships code. There is no separate "publish" button, no scheduled queue, no CMS-to-production sync that can be half-applied.
Two escape hatches sit in the frontmatter and cost nothing:
published: falsekeeps a finished draft in the repo and off the site. It's a filter inlib/content.ts, not a comment-out — the file still has to compile.- A future
dateis just a date. The file merges today and the listing sorts it where it belongs.
The one thing you give up is decoupling: every new post is a build and a deploy of the site image. On a site that publishes daily that's a real cost, and it's the second consequence we wrote into the ADR. It's also, at ~30 seconds of warm build time, a cost we're happy to keep paying for the operational surface it removes.
What doesn't belong in git
Text does. Binaries don't. The covers on this blog are hand-authored SVG, so they're source like everything else — a diff shows you which rectangle moved. Photography, screenshots and video are the opposite: every raster asset lives in a Bunny storage zone behind cdn.codeagency.be and is referenced by URL, with pnpm media:upload pushing the file to a content-hashed immutable path and printing the URL back.
The distinction isn't fussiness. A repo that accumulates 4 MB hero images carries them in its history forever — git stores a fresh blob for every re-export, and a clone pays for all of them. Text it can diff and pack; pixels it can only hoard. So the line is: if a human wrote it and a diff can explain it, it's in git. If it came out of a camera or an export dialog, it's on the CDN.
Typed content is worth more than the publishing story
The publishing workflow is the visible half. The half we'd actually miss is that content becomes an import:
import { allPosts } from "content-collections"
export const publishedPosts: Post[] = allPosts
.filter((post) => post.published)
.sort(featuredFirst)allPosts is a typed array, generated at build time. Everything downstream — the blog index, the client-side fuzzy search, tag pages, sitemap.ts, the RSS feed, the related-posts blocks on service pages — derives from that one array with full type checking. Rename a frontmatter field and TypeScript tells you every place that reads it. There's no "which pages use this content type?" investigation, because the compiler already knows.
It has one sharp edge worth naming: client components must never import the collection, or the bundle swallows every post body on the site. Slim card types in lib/format.ts cross that boundary instead. That's a real constraint of build-time content, and cheaper than the runtime fetch it replaces.
When you should just use a CMS
Copy this architecture when the people writing the content are the people writing the code. That's it — and the honest version of that test is not "could a marketer learn git", it's "will they, at 4pm on a Friday, to fix a typo".
Use a CMS when there are several non-technical authors, when publishing has to happen without a deploy, when someone needs to schedule a campaign page, or when the client will own the site after we hand it over. Those are ordinary requirements and we build for them constantly — usually as a headless setup where the editing surface is a CMS and the rendering is still static, which keeps most of what's described above.
And if this site ever needs one, the typed collection layer is the thing that makes it a contained job: swap the source that produces allPosts, keep every consumer. That's the useful shape of the decision — not "no CMS forever", but "no CMS until something specific forces one, and a schema in the middle so the migration is a day and not a quarter".
Want us to publish something specific?
Tell us what you'd like to read and we'll add it to our writing queue.
Get the next one in your inbox
New articles, videos and the occasional engineering note — a short mail when there’s something worth reading, nothing else.