Skip to content
Engineering case studyAll work

A heritage society's site,
built twice on purpose.

The Greek-Scythian Society CIC researches the peoples of the ancient Eurasian steppe, and needed a public face serious enough for museums, universities, and grant panels — that they could also maintain themselves. I built it on WordPress, ran it for a month, and then replaced my own build with a static one. The design survived the move; the servers did not.

The problem

The stack outweighed the thing it was serving.

The Society needed a real public record — research, events, projects, partners, and an archive of photographs from the reconstructions they exhibit at each year. They also needed to add to it themselves, without hiring a developer every time an event date changed. WordPress is the honest answer to that brief, so that is what I built: a custom plugin holding the content types, a custom theme holding the design, provisioned by one idempotent script.

Then it ran for a month. The content changed a few times. Meanwhile a five-service stack on a single small VM had to be patched, backed up, monitored, and paid for — a standing operational cost, carried by a volunteer-run nonprofit, for a site that is almost entirely read. The right call was to keep everything they could see and throw away everything they could not.

The core decision

Same design. Same URLs. No servers.

Two phases, and the second one had to be invisible to anyone visiting the site — which is the constraint that shaped it.

Phase 1 · WordPress

Bespoke, because the template was rejected

Content types live in a custom plugin and the design in a custom theme, so the two can be replaced independently — the plugin refuses to render its own markup when the theme declares it handles that. The whole environment comes up from a 174-line idempotent provisioning script: bare VM to finished, populated site in one command.

Phase 2 · Static

The CMS was the scaffolding, not the building

Content was exported from the WordPress REST API into JSON and became the source of truth. The theme's CSS carried across, so the pages look the same. Trailing-slash URLs preserve the original address of every page, the existing SEO copy came with it, and retired event slugs redirect rather than 404. 23 pages, three runtime dependencies, nothing to patch.

Architecture

Content out of the CMS, pixels left alone.

The migration path in four steps — and the three subsystems that had to be rebuilt rather than copied.

01
WordPressgss-core plugin + gss-journal theme
02
REST API exportpages · events · projects · partners → JSON
03
Next.js 15.5static generation · theme CSS carried over
then fans out to

URL continuity

trailing slashes preserved · retired slugs redirect

Images

WebP · art-directed crops · responsive srcset

Contact form

HMAC-signed token · 3s fill floor · Resend

Typography

self-hosted · Latin and Cyrillic subsets

Engineering decisions

The decisions worth explaining.

A few of these were mine to make. The interesting ones were forced by the material — photographs, a client's memory, and a form that had been lying to everyone who used it.

Three real designs, not three descriptions

The first draft was built on an off-the-shelf theme and the client judged it not good enough. Rather than argue about it in the abstract, I built three complete directions as real, browsable pages — an exhibition treatment, a field journal, and an institutional one — and let the decision be made on the actual thing. The field journal won and became the bespoke theme. All three are still in the repo, because they are the record of how the choice was made.

The photographs were being decapitated

Source images run from tall portrait to wide panorama, so any fixed frame throws part of the picture away. The browser's default centre-crop cut the head off a carved stele — a poor advert for a heritage society. Shrinking everything to fit made the whole page weaker. The fix was a per-image crop map naming what has to survive, and a one-sided rule: a panorama loses width it can spare, so it crops; a portrait would lose its subject, so it gets matted against the dark ground like a catalogue plate.

A spam check the spammer could read

The original form asked visitors to add two and three. The answer was a constant sitting in the public HTML, so it stopped no one — it could be, and was, driven straight from the command line. It is now an HMAC-signed token minted when the form loads, valid for an hour, checked in constant time, with a floor of three seconds on how fast the form can be filled. It proves the sender actually loaded the page and took a human amount of time, and it asks the visitor nothing at all.

The form was quietly eating enquiries

WordPress was routing mail into a local capture tool — correct for a draft host, inherited into production, which meant every message was politely accepted and then dropped. The rebuild inverts that failure mode: the form only renders when delivery is actually configured, and a delivery failure returns an error to the sender rather than a cheerful success, because the delivered email is now the only record. It sends over Resend's HTTP API — a serverless function has no fixed outbound IP, so an SMTP allowlist was never going to work — using a key that can send as the domain and do nothing else.

Where memory and evidence disagreed, the photographs won

Several events were placeholders reading "full details to follow". When the real record arrived, the remembered dates did not match the EXIF data on the photographs from those same days — and the client's own wording elsewhere settled a question about how many occasions there had actually been. Dates follow the photographs. Two placeholders turned out to describe events already listed and were merged rather than duplicated; retiring the rest surfaced a bug where a draft entry was still rendering, because the exported status field had never been read. Every image got alt text, which the migrated media had never had.

Replacing my own build was the deliverable

The client's real requirement was near-zero maintenance, and the WordPress phase could not give them that no matter how well it was built. Static could. The typography is self-hosted rather than pulled from a font CDN — two fewer connections before first paint, and two fewer third-party requests a UK nonprofit would otherwise have to describe in its privacy policy. The hero image alone came down from 361 kB to 120 kB through a responsive srcset, with no visible difference.

Delivered

Live, static, and theirs to keep.

  • Live at greek-scythian.org — 23 pages, fully prerendered, no database and no server to maintain.
  • The WordPress phase is public under GPL-2.0: custom plugin, custom theme, five-service Docker stack, 174-line provisioning script.
  • Every original URL still resolves, the existing search copy came across, and retired pages redirect instead of 404ing.
  • Three runtime dependencies. The largest image on the page ships at 120 kB instead of 361 kB.
  • Typography self-hosted in Latin and Cyrillic — the groundwork for the Ukrainian and Russian versions on their roadmap.

Two phases, one client

Delivered between 18 July and 19 August 2026. The first phase answered the brief as written — a CMS they could edit — and the second answered what they actually needed once the site was real, which was for it to stop costing them anything to run. Phase two shipped in three days because phase one had already settled every design question.

What's next

The Ukrainian and Russian versions the Society has planned — the typography and content structure are already sitting ready for them.

Need a site that outlives the CMS it was built on?

Custom sites, built clean and handed over — no templates, no monthly fee holding your content hostage, and the code is yours from day one. If you already have a site that costs more to keep running than it should, that is a conversation worth having too.