Quick Summary
- The old site worked, but the Site Editor’s limits had gradually narrowed the layouts and design choices I considered.
- I tested a headless WordPress setup first. The test showed that I would still write the publish trigger and generate the metadata myself.
- Each essay is now a folder containing one Markdown file and its media. Its frontmatter date controls when it is published.
- Ordinary prose stays portable Markdown. Explicit MDX modules handle figures, galleries, summaries, callouts, and attributed quotations.
- A model suggests old essays that could link to a new one, while sentence embeddings generate related posts for readers.
- Mobile performance rose from 81 to 97, even after I added custom fonts, cover images, and the layout I wanted.
- The migration took four weeks and 220 commits. It suited what I wanted to learn and build, but it is not a general prescription for leaving WordPress.
AI-generated summary based on the text of the article and checked by the author. Read more about how BUT. Honestly uses AI.
I never disliked the old site. That was the problem.
If I had hated it, I would never have shipped it like that to begin with. Instead I liked it fine. It loaded, it looked reasonable, people read it, it had no serious performance concerns. Every time something bothered me I opened the Site Editor, found the block that almost did what I wanted, and shipped the almost.
That’s not a dramatic failure. Nobody writes an essay about a site that works.
But the almosts add up. After enough of them you stop asking for the thing you actually want, because you already know the answer. You start designing inside the shape of the tool, and after a while you can’t tell where the tool’s limits end and your taste begins.
I only noticed once it was gone.
It runs on Astro now. The essays are Markdown files in a folder, the build is static, and nothing gets assembled while you wait. That part took a month. Noticing why I wanted it took years.
What I Was Actually Annoyed By
The Site Editor isn’t a bad piece of software. It’s a general-purpose tool doing a general-purpose job, and it does that job for millions of sites that aren’t mine.
That’s the friction. My site is one person writing essays. I don’t need a theme that can become a restaurant, a storefront, and a portfolio. I need a reading column, a typographic scale I chose, and the ability to change one of them without discovering which four other things it also changed.
Every fix was a negotiation. I would want a small change — spacing, a border, the way a caption sat under an image. The path to it ran through a settings panel, then a theme.json value, then some custom CSS wedged into the Additional CSS box. Which is where styles go to be forgotten.
Sometimes I went searching for a plugin that adds the one block I wanted. More often I wrote the PHP snippet myself. The alternative was paying for a plugin whose broader feature set I didn’t need.
The result worked, but its design came from the paths the Site Editor made easiest.
The difference is easiest to see side by side. The old home page put a tall wordmark in a column of its own and stacked everything else beside it. That was the arrangement the blocks made comfortable. The new one has a masthead across the top, a dated lead essay, and a reading width I chose on purpose.


The changes are small. The second layout is what I wanted the whole time, and it was never more than a layout decision away.
The Plan I Abandoned

There was a longer route to the same place, and I had already mapped most of it.
I first planned to go headless: keep WordPress as the place I write, have Astro read its REST API at build time, and deploy the result as static files. It’s a well-worn migration path and a sensible one. You keep the editor you know and get the front end you want.
I rehearsed it on my portfolio site first. A few posts, two pages, nothing much to lose if it went badly. The pre-flight was a short checklist: does the API return what I need, is the SEO data there, which URLs break.
Most of it passed. The API handed back clean JSON with excerpts, featured images and taxonomy terms, exactly as documented.
The SEO check also passed, but there was no plugin output worth inheriting. The Jetpack fields existed and were empty, so I was going to generate the new site’s metadata in Astro myself.
The publish trigger settled the decision. A static site has to be told when to rebuild. In a headless setup that trigger is a webhook, fired on publish. WordPress.com doesn’t have one on plugin-enabled sites.
So I was back to two options: install a plugin built for a wider set of publishing needs, or write the one function myself.
I wrote the function. It’s about as small as you would expect, it works, and it still runs on my portfolio site today.
This was the same trade I had been making inside the Site Editor for years, one layer further down. I needed one specific function, while the available plugins solved a broader problem.
At that point I looked at what I actually had: a publish trigger I wrote, metadata I was generating myself, and a front end I was about to build from scratch.
WordPress was still holding the text in a database and giving me an editor I no longer needed.
The checklist said go. My instinct said the backend no longer justified the extra layer, and I have written before about trusting that instinct on WordPress decisions.
I was one phase away from carefully preserving the exact thing I wanted to leave.
Markdown, Obsidian, and a Folder
An essay on this site is a folder.
Inside it sits one Markdown file named after the folder, and the images that belong to it. That’s the entire content model. No database, no media library, no post ID. If I want to know what an essay is made of, I open the folder and look.

An essay open in the vault. The folder holds the text, the images and the narration together, and the properties above the text are the frontmatter the site builds from.
I have written in Markdown for most of my work since 2015, so this part wasn’t a leap. What changed is that the Markdown is now the thing that ships, rather than something I paste into a form afterwards.
I write in Obsidian, which is pointed straight at the content directory. The vault and the site are the same files. There’s no import step and no export step, because there’s nowhere for the text to go.
The frontmatter date controls publication.
There’s no draft toggle and no status field. A date in the future means the essay is scheduled; once that date passes, an hourly job on GitHub notices and rebuilds. Work in progress lives in a drafts folder that the site doesn’t build at all. Moving the folder is the act of publishing.
Two implementation details changed the content model.
Narration used to carry voice, style, and pace settings in every essay. Those settings belonged to the generation tool, not the site. The essay now keeps only an optional audio filename, and the player derives the rest from one fixed media location.
The other is smaller. Every field in my new-essay template ships blank, and a blank YAML value arrives as null rather than as nothing at all, which a strict schema rejects. So the schema has a preprocessor that turns empty into absent. It took twenty minutes to find and one line to fix.
Readers will never notice either detail, but I can find both compromises in code I own.
Keeping Markdown Ordinary
Here is the problem with writing in one tool and publishing somewhere else: private syntax follows the tool instead of the writing.
The durable choice was ordinary Markdown for ordinary prose. Internal links are normal links, so both the source and the built site agree on where they go:
I have [written about this before](/write-in-markdown/).
Exceptional layout is explicit MDX. The extra structure appears only where the page genuinely needs more than prose.
The rule I set was that the file has to make sense in Obsidian and on the site. Every time I broke that rule the writing got worse, because I started thinking about output while drafting.
Galleries are the clearest case. I wanted a grid of images. The obvious way is a <div> with a grid class, and it works. But Obsidian’s Live Preview hides raw HTML until you click into it, so while writing I would see a blank gap where two photos should be. The images inside it would also skip the build’s image handling entirely.
So a gallery names exactly what it is:
<Gallery columns={2}>
<Figure src={homeOld} alt="The old home page" />
<Figure src={homeNew} alt="The rebuilt home page" />
</Gallery>

The old gallery block as Obsidian previewed it while I wrote.
That is the comparison from earlier in this essay, seen from the other side. The module turns the figures into a two-column grid, and the images go through the same pipeline as every other image on the page.
The semantic modules are in the repository, which is public and MIT licensed. The essays themselves stay mine, under a Creative Commons licence — the front end is the part I am giving away.
Here, “fully customizable” means the constraints are mine and written down in a file I can open. Some changes are still hard, but I can make them without waiting for a release cycle built around a much broader range of sites.
Images and Audio Live in Opposite Places
Images are in git. Audio isn’t. That difference took me a while to arrive at, and it explains most of how both work.
An image is a build input. The JPEG I commit isn’t what you download — Astro re-encodes it to AVIF and WebP at the right sizes, and your browser picks. Keeping the source in the repo means the build can always redo that work when the tooling improves.
That makes the rules around images strict.
A cover has to be 16:9, and a script checks before every commit. If it isn’t, the commit stops. That sounds heavy-handed for a personal blog. But resizing by width never crops. A 4:3 cover doesn’t get letterboxed, it gets stretched, and I’d have shipped it without noticing.
Body images are looser, because a wide dataset strip or a tall diagram is fine. Only width matters there.
The optimizer also converts anything opaque to JPEG and rewrites the Markdown reference to match, so I can drop a PNG in the folder and forget about it. Transparency stays PNG, since JPEG has no alpha. Animated GIFs get skipped entirely, because AVIF is a single frame and a GIF would arrive as a still.
Audio goes the other way. The narrations are tens of megabytes each, they’re generated rather than authored, and nothing in the build depends on them. They live in object storage and the repo never sees them.
What I didn’t expect was that generating them would turn into an editing pass.
The narration script runs twice. The first run costs nothing. It reduces the essay to what will actually be spoken, splits it into chunks, and writes that to a file beside the Markdown. Then I read it.
An acronym that needs spelling out, an image caption that made sense under a photo and makes none in the ear — those get fixed there. The second run synthesizes from the edited file, not from the essay.
So the spoken version isn’t a reading of the page. It’s a version of the essay edited for being heard, which is a thing I didn’t know I wanted until I had it. There’s even a pair of markers for passages that should exist in only one medium, though reaching for them usually means the sentence needs rewriting instead.
The last piece is a check I’m fond of. After synthesis the script compares the speaking rate of every chunk against the median for that run, and flags the outliers. A chunk much faster or slower than the rest is usually one where the model dropped a clause or repeated one. It doesn’t tell me the narration is correct. It tells me which ninety seconds to actually listen to, which for a long essay is the difference between checking the work and pretending to.
The Links Between Essays
There are two kinds of links on this site. The ones I choose, and the ones the site chooses.
Both were worse before, in different ways.
Internal Links
The ones I choose are internal links, and I had that problem backwards for years.
I can add a link in seconds. Finding useful places for it is the expensive part.
Every new essay creates link opportunities inside essays I wrote months or years ago. A paragraph from 2023 that should now point at something I published last week. Nobody sees those unless somebody goes looking, and the somebody is me, rereading forty-five essays I know far too well to read carefully.
So I did it once, at publication, and then I mostly didn’t. The archive slowly drifted out of date with itself.
There are tools for this. SEO plugins that crawl your content and suggest where to link. The ones that work well are very expensive, and the ones I tried at the price I was willing to pay didn’t work well enough to keep.
Now the whole body of work is plain text in one folder. I can point a model at it and ask where a new essay should be linked from, and get back specific paragraphs in specific posts. Then I read the list and throw half of it away, because a suggestion is only a suggestion and the judgment is the part worth keeping.
Editing the file afterwards takes seconds. Two brackets, and Obsidian autocompletes the slug.
What changed isn’t the linking. It’s that finding got cheap, so it actually happens.
Related Posts
The ones the site chooses are the related posts under each essay, and those I was never happy with.
Jetpack’s version does more than people assume. It doesn’t just match tags. It runs the actual post content through Elasticsearch on WordPress.com’s servers, and weighs categories and tags alongside it. It won’t render at all unless it finds at least three results it rates as good. All of that happens in their cloud, so it costs your server nothing.
It’s a good piece of engineering. It just wasn’t as precise as I want it to be.
On this site, the results still behaved mostly like lexical matching. Two essays using the same words would score as related. Two essays making the same argument in different vocabulary often don’t. So a post about WooCommerce coupons can land under a post about leading a team, because both talk about a store. Meanwhile the essay that genuinely continues the argument sits elsewhere, using none of the same nouns.
The commercial alternatives I found cost more than I wanted to spend on related posts, while the free options I tested produced less useful matches on this archive.
So now a neural network does it.
Every essay gets run through a small sentence-embedding model, which turns the text into a vector — a few hundred numbers describing roughly what the piece is about. Two essays that argue similar things end up close together in that space. For each essay I take the six nearest neighbours, nudge the score slightly for a shared tag or category, and write the result to a file.
That file is committed. The site reads it at build time and never at runtime, so nothing is computed while you’re waiting for the page.
The parts I like are the boring ones. The model is about two hundred megabytes, and it isn’t a dependency of the site. It gets installed only inside the job that regenerates the map, so the deploy stays light.
Each essay’s vector is cached against a hash of its text, so an essay only gets re-embedded when I actually change it. An essay published before the map catches up still gets results based on shared tags and recency.
The system measures similarity, not comprehension, and it has the same limits everything else in this family has. It puts two essays together because their meaning points the same way, which is not the same as understanding either one. When it is wrong, it is confidently wrong.
Jetpack and my system approach related-post matching differently. On this site, semantic similarity gives me more useful results than shared vocabulary.
I also prefer mine because I can open the file, see the weights, and change them. If tomorrow I want the tags to have more importance than the categories, I can do that.
What Runs Without Me
A commit starts the publishing process.

Four scheduled jobs watch the repository. One rebuilds the site when an essay’s date comes due. A piece written in June goes live on a Tuesday in August without me opening anything.
One regenerates the related-posts map. One tells Bing, Yandex, Seznam and Naver that something changed, since Google doesn’t take that kind of notification and finds it through the sitemap anyway. One emails subscribers.
Every one of them also runs on a daily timer, for a reason that took me longer to see than it should have. A date passing is not an event. Nothing happens when the clock rolls over an essay’s publish time — no webhook, no signal, nothing to react to. Something has to wake up and check.
The harder part was making sure a job didn’t do its work twice.
Each of them keeps a small ledger, committed alongside the site. Which essays have been announced, which URLs have been submitted, and a hash of the content each time. Resubmitting unchanged URLs is how you get throttled by a search engine. Re-announcing an essay is how you email the same person the same thing on a Tuesday and again on a Wednesday.
The newsletter ledger had to be seeded before the first run.
Without it, the job would have looked at forty-one already-published essays and correctly concluded that none had been announced yet. Then sent forty-one emails to everyone who had ever subscribed. It would have been right about every single one of them. That’s the failure mode of automation I keep running into: the script does exactly what you asked, at a scale you didn’t picture.
So the jobs are careful, and boring, and mostly do nothing. An hourly check that finds no essay due exits in a few seconds. That’s the correct outcome, several hundred times a week.
What I get for it is the thing I actually wanted. I finish an essay, set a date, and commit. Whether it goes out at nine in the morning while I’m asleep is not my problem anymore, and it isn’t anybody’s server either.
It Got Faster, But That Wasn’t Why
The old site was not slow. It scored 81 on mobile and 100 on desktop, and I know those numbers precisely because I wrote an essay about them in March.
Reading that essay again changed how I understood one passage:
This blog has no fancy animations, no custom fonts, and barely any images. That’s entirely by design.
I presented that as discipline. It reads now like an inventory of things I had given up.
I even wrote down the rule I was following. Keep mobile above 80, retest after every design change, and if the score dips significantly, the change wasn’t worth it. That policy sounded reasonable, but in practice it made me reject design additions whenever they lowered one score. I had followed it for years without noticing how much it ruled out.
The new site scores 97 on mobile and 100 on desktop. It has custom fonts. It has a cover image on every essay, a typographic scale I chose, and a reading column measured rather than inherited.

PageSpeed Insights on desktop after the rebuild.
The desktop score is the interesting one, because it didn’t move and it couldn’t. It was already at the ceiling. But the metrics underneath it are not the same at all:
| Desktop | Old | New |
|---|---|---|
| First Contentful Paint | 0.5 s | 0.2 s |
| Largest Contentful Paint | 0.7 s | 0.5 s |
| Speed Index | 0.6 s | 0.4 s |
| Cumulative Layout Shift | 0.048 | 0 |
A score of 100 says the page cleared Lighthouse’s bar. The metrics underneath show that the new site completes some of the same work in roughly half the time.
The mobile gain is the same change made visible. Mobile testing assumes a slower processor and a worse network, so it punishes work. Nothing here is optimized in a way the old site wasn’t. There is simply less to do.
A page is a file. It was built days ago, it sits on a CDN near whoever asked for it, and answering the request means handing it over. No PHP starts up, no database is queried, no plugin gets a chance to add a script to the head.
The whole site ships about thirteen kilobytes of JavaScript across five small files, none of it a framework. Images arrive as AVIF at the size the layout actually uses, with their dimensions known in advance, which is the entire reason layout shift went to zero. Nothing reflows, because nothing arrives unannounced.
The page loads faster because fewer systems take part in delivering it.
A personal essay site is the easy case. No logged-in users, no cart, no comments, nothing that has to be true at the moment you ask for it. Everything I removed was something I could afford to remove, which is not a general argument about anything.
The sixteen-point mobile gain is real, but the useful result is that I can keep the fonts, images, and layout I want without dropping below my performance target.
What It Cost
Two hundred and twenty commits in about four weeks. That headline number doesn’t include the month of planning that produced a migration I then abandoned.
The rest of the cost is quieter. WordPress gave me a set of things for free that I now own outright: search, the newsletter, related posts, the RSS feed, the sitemap, every meta tag. None of those were hard. All of them are mine to fix at eleven at night when something breaks.
The redirects were the part that actually frightened me. Ten years of URLs in three or four different shapes, all of which strangers have linked to and search engines have indexed. That’s a hundred lines of rules in a file, and if I got one wrong, the failure is silent. Nobody emails you to say a link from 2018 stopped working.
There is no wp-admin anymore, which is mostly the point and occasionally the problem. A typo in a published essay is now a commit, a push and a build. I can’t fix one from my phone while standing in a queue.
Part of my motive was learning Astro, TypeScript, and Tailwind properly. A real site with real readers is the only way I ever learn anything properly, but that reason is personal to me.
If your site works and you don’t want to spend a month rebuilding it, keep it. Mine worked too.
Settling Is Quiet
Astro and WordPress answer different questions. I kept asking WordPress for precise control over one essay site, even though it was built to serve a much wider range of sites. I ran into the same problem while learning to build for the web.
The performance rule made sense each time I applied it. So did choosing the block that almost matched the layout I wanted. I had other work to do, and each compromise was small enough to accept on its own.
I saw the pattern only after I rebuilt the site and could compare the choices side by side.
I don’t think the old site was a mistake. I think I stopped being able to see it and love it.
