Building RiantCMS: Why This Blog Runs on 3,000 Lines of Swift
The blog you’re reading isn’t served by WordPress, Ghost, Hugo, or any hosted platform. Every page here is generated by RiantCMS, a small static-site engine we wrote ourselves — in Swift, the same language our apps are built in. This post is about why we did that, what the thing actually is, and the handful of lessons that surprised us on the way. It’s also a small milestone: the post you’re reading was published by the software it describes.
The problem was smaller than a CMS
When we decided riantstudio.com needed a blog, the requirements list was honest and short: a few posts a month, one author, Markdown from a plain text editor, images that don’t embarrass us on mobile, and hosting on the small server we already run. Nothing per-visitor, nothing interactive.
That list matters, because almost everything a traditional CMS provides — admin dashboards, user roles, a database, a plugin ecosystem — exists to solve problems we don’t have. What WordPress would have contributed to our actual requirements is mostly surface area: a PHP runtime and a MySQL database to patch forever, on a server that also runs our apps’ backends. A blog updated a few times a month doesn’t need software that answers requests; it needs software that writes files.
So: files in, files out
RiantCMS is a command-line tool. Posts are Markdown files with a small
front-matter header, sitting in a git repository next to their images.
riantcms build turns that folder into a complete website — HTML pages,
a blog index, tag pages, an RSS feed, a sitemap — and riantcms deploy
rsyncs the result to the server, where nginx serves it as flat files.
That architecture buys three things we care about:
- Nothing to attack, nothing to patch. The “production system” is a directory of static files. There is no admin login to brute-force and no plugin update to miss.
- The content is ours, forever. Posts are plain text in version control — diffable, greppable, portable to any future system in an afternoon. For a studio whose whole pitch is your data, your device, blogging out of somebody’s database felt off-brand.
- Fast by default. Pre-rendered pages with aggressively cached, content-hashed assets. No cache plugin, no tuning — there’s nothing to tune.
Why write our own instead of using Hugo or Jekyll? Honestly: because the existing tools share nothing with our stack. We write Swift all day; the templating (Leaf) and the config patterns in RiantCMS are the same ones running riantstudio.com itself, so every part of the engine is code we already know how to maintain. The whole thing is about three thousand lines — small enough to read in a sitting.
Three lessons the plan didn’t include
The image format chose itself. The plan said WebP for responsive
images. It turns out Apple’s ImageIO — which we use because builds run on
a Mac — can decode WebP but exposes no WebP encoder. AVIF, however,
is right there, compresses better, and has had universal browser support
for years. So every image on this blog is served as content-hashed AVIF
variants in a <picture> element, with the original format as fallback.
Sometimes the constraint makes the decision for you, and it’s the right
one anyway.
Our own security headers bit us. When we added client-side search to
the blog index, the shipped page looked perfect — and the search box never
appeared. riantstudio.com sends a strict Content-Security-Policy that
forbids inline scripts, and the search was an inline script. The browser
was doing exactly what we had told it to, silently. The fix (an external
same-origin script) took minutes; the reminder was free: hardening you
did months ago will eventually test you, and that’s it working.
We almost put the content in a database. As the posts folder grew, a hybrid design was tempting: keep drafts as files, move published posts into SQLite. We stopped at the obvious question — how do you re-render every page after a template change if the source lives in database rows? The version we shipped keeps files as the single source of truth and uses SQLite only as a disposable index: full-text search and a publish log, rebuildable from the files at any time. Delete the database and nothing is lost. That one principle — derived data may live anywhere, authoritative data lives in files — resolved every design argument after it.
The workflow, as it stands
Day to day it’s now a small pipeline: riantcms new post scaffolds a
draft; drafts live in their own folder, so a post’s state is simply
where it is. riantcms schedule pencils in a publish date, and
riantcms publish promotes whatever is due — stamps the date, moves the
bundle into the published tree, records it in the index, builds, and
deploys. Deliberately no cron: we run publish when we mean it. And the
search box on the index is a few kilobytes of JSON and vanilla
JavaScript, generated at build time — no search server, and if
JavaScript is off, the full post list is simply there instead.
The same principle handles the end of a post’s life. Retiring a post
from a CMS usually means deleting it — and its history with it. Here,
riantcms archive just moves the bundle to an archive folder: the post
drops off the site, the index, the feed, and the search box, but the
files stay readable, the publication record stays in the local index,
and riantcms archive --restore brings it back untouched. Every state a
post can be in — draft, scheduled, published, archived — is a folder you
can look at, which means the whole content lifecycle is inspectable with
ls and recoverable with mv. No CMS taught us that; the filesystem
already knew.
Would we recommend this?
Building your own blog engine is only sensible when the ingredients line up: a technical author, a low posting cadence, an existing server, and a stack you’d enjoy extending. All four were true here, and the result fits like a made-to-measure suit — plus we learned things about our own platform that a WordPress install would have hidden from us. If any of those ingredients are missing — co-authors who don’t write Markdown, comments, real publishing volume — take the boring option; it’s boring for good reasons.
But if the ingredients do line up, there’s a quiet satisfaction in a
publishing pipeline where you’ve read every line, and where “the CMS
needs maintenance” means swift build. This post went live with one
command. That never gets old.