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.