Technology

Building my own CMS, part 1: one admin, plain files out

Diagram of the CMS. I sign in through Cloudflare Access to the CMS, which pushes to GitHub, and FlyWP deploys each site. Below it sit backups to Cloudflare R2, email through Amazon SES, social accounts and a drafts inbox.

A few weeks ago I wrote that I got tired of WordPress and built my own CMS. That post was about why. Several people asked how. This series is the answer, in five parts:

  1. One admin, plain files out (this post)

  2. Publishing through git

  3. Locking down the admin

  4. Moving thousands of posts out of WordPress

  5. Running it: deploys, backups and a restart I had to explain

I built it with Claude, Anthropic's AI model, working in Claude Code. I wrote the requirements, made the decisions and tested everything. Claude wrote the code and the tests. Where it got things wrong, I say so.

The screenshots come from a copy of the CMS running on a test machine with sample posts. Email addresses, IP addresses and server names are blurred.

The spec came first

Before any code, I wrote a specification and signed it off. It went through five drafts. The last one, dated 6 October, is a few pages long and has a table called "Decisions settled": admin address, which sites, URL format, security, editor, users, comments, hosting, email, images, social, automation. Each row is one line.

That table did more work than anything else in the project. When you build with an AI model, the model will happily build whatever you describe, including the thing you half described. A settled decision is something it cannot drift away from, and something I can point back to when a change quietly contradicts it.

Two decisions changed after sign-off, and both changes were deliberate:

  • Address. The spec said every blog lives on a subdomain, blog.parthans.com. I moved the blog to parthans.com/blog instead, because Google treats a subdomain largely as a separate site. The first post explains that.

  • How files reach the server. The spec had the CMS writing straight into the blog folders on my server and swapping them live. I changed that to GitHub. More on why in part 2.

Changing a spec is fine. Changing it without noticing is the problem.

What it is made of

Diagram of the CMS. I sign in through Cloudflare Access to the CMS, which pushes to GitHub, and FlyWP deploys each site. Below it sit backups to Cloudflare R2, email through Amazon SES, social accounts and a drafts inbox.

The whole thing is one small web application. It runs in a Docker container on my VPS, deployed by FlyWP, the same panel that runs my other sites.

  • Next.js 15 and React 19 for the admin screens and its API. Most screens are rendered on the server.

  • SQLite, through Node's built-in node:sqlite. One file holds every post, user, setting and log line. No database server to run, patch or back up separately. It needs Node 22.13 or later.

  • Tiptap for the editor. It feels like Google Docs and stores clean HTML.

  • sharp for images. Every upload is re-encoded and resized into WebP at the sizes the blog needs.

  • sanitize-html to clean post HTML to an allowed list, on save and again on build.

  • cheerio to read and rewrite HTML: imported WordPress posts, and each site's own header and footer.

  • turndown, for the one site that wants Markdown instead of HTML.

  • Argon2 for password hashes and qrcode to show the two-factor setup code.

The post editor. A Google Docs style toolbar above the draft, and on the right the Publish panel and the writing rules check, which has flagged a banned word in the draft.

Eighteen packages in all. I asked for few dependencies on purpose: there is no vendor shipping security patches, as there is with WordPress. Every package I add is one more thing I have to keep current.

Four sites, one admin

The CMS publishes four blogs:

Site

What it gets

parthans.com/blog

Static HTML

aicontentops.online/blog

Static HTML

aartikrishnakumar.com/blog

Static HTML, split into two sections, Work and Tales

onlybikerstuff.in/blog

Markdown files, which that site's own app renders

Each site has its own settings, its own theme folder and its own editors. I see everything. Anant sees AI Content Ops. Aarti sees her own site and nothing else.

The Posts screen. Tabs across the top filter by site and by status, and a table lists each post with its site, status, date and social posts.

Why plain files

WordPress builds every page on every visit. It runs PHP and MySQL around the clock to show the same article to the thousandth reader that it showed the first.

This CMS builds a page once, when I publish. After that, the web server hands over a finished file. There is no login page, plugin or database on the public site for anyone to attack, because there is nothing running there at all. If the admin is switched off, every blog keeps working.

The build is a pure function: the database plus the site's own header and footer go in, and files come out. The same inputs always produce the same bytes. That property turns out to matter a lot, and part 2 explains why.

Each of my sites is hand-built HTML. I did not want a second copy of each menu inside the CMS that I would forget to update.

So at every build, the CMS opens the site's own index.html, takes the header and footer elements and the stylesheet links, and wraps every blog page in them. Change a menu on the main site, and the next publish picks it up. The blog's own styles sit in a small theme folder and load after the site's stylesheet, so the blog builds on the site's look rather than replacing it.

The parts that run on a timer

One process does everything. Inside it, a scheduler wakes every minute and:

  • publishes any post whose scheduled time has passed

  • sends social posts, once the page is actually live

  • checks the drafts inbox each morning at 10

  • runs the nightly jobs after 01:30 India time: an encrypted backup, session clean-up, token refreshes

There is no separate job runner and no queue service. For four personal blogs, a timer inside the app is enough. It does mean that restarting the app interrupts whatever the timer was doing, which is the story of part 5.

The rule that shaped everything else

One line in the spec mattered more than the rest: it never publishes on its own. Only a signed-in person can publish or schedule a post. Scheduling counts as approving in advance. No automation, including any I build later, can publish.

That rule decided several designs that would otherwise look odd. Bulk actions on the Posts screen can move, archive or delete posts, but they cannot publish them. When I want Claude to draft a post in another session, it pushes a Markdown file to a private inbox repository, and the CMS turns it into a draft. Nothing that arrives through the inbox can be anything but a draft, whatever the file says. The only thing the CMS writes back to that repository is a list of existing posts, so a drafting session can avoid repeating one.

By the numbers

  • First commit: 10 October 2026

  • About 9,800 lines of JavaScript across the app and its logic

  • 71 commits to date, each one a change I asked for and checked

  • 69 automated tests, run before every change goes out

Next, part 2: how pressing Publish turns into a single git push, and why I stopped using GitHub's API to do it.

Send feedback

Goes to my inbox. Nothing is published.

All posts