Skip to content

How this site is built

This site ran on Hugo for two years and now runs on Astro. What I moved, what I refused to move, and three bugs that produced no error at all.

Marcello Martini10 min read

WebAutomationTooling

Every personal site I built before this one died the same way. A CMS I stopped updating, a database I had to back up, a plugin that broke on a version bump. The fix was to make the site something I already maintain every day: a repository.

That part has not changed. There is still no server, no database, nothing to patch. What changed is the thing that turns the text files into HTML.

Why I left Hugo

Hugo

Still the fastest thing I have used for a site that is only pages. A full rebuild in about four hundred milliseconds, which spoils you.

Not for speed. Hugo rebuilt this entire site in roughly four hundred milliseconds. Astro takes about thirteen seconds for forty four pages. On that number alone I should have stayed.

I left because of what the site had become. It was no longer only pages. There was a chat widget, a photo slideshow with filters, a travel map, a search modal, a theme toggle, a consent banner. Every one of them was hand written JavaScript in a <script> tag, reaching into markup produced by a Go template that knew nothing about it. Nothing checked that the class a script queried still existed. Nothing checked that a field a template read was still in the YAML.

The trigger was mundane. I renamed a key in a data file and a section quietly vanished from three pages, because Go templates resolve a missing field to the empty string and carry on. That is correct behaviour for a template engine. It is a bad property for a site with a hundred interlocking data fields.

The rule: not a single URL changes

Before writing any code I set one constraint. Every URL the old site served, the new one serves, at the same address, or the migration failed.

Two settings carry most of that:

build: { format: 'directory' },
trailingSlash: 'always',

Hugo published everything as <route>/index.html with a trailing slash. Keeping both means no redirect layer and no changed link.

The interesting one was casing. Hugo ran with disablePathToLower, so a file named SE2-Project.md was served at /projects/SE2-Project/. Astro’s content loader lowercases ids by default, which would have moved that page and broken every inbound link to it without anything looking wrong:

const verbatimId = ({ entry }: { entry: string }) => entry.replace(/\.mdx?$/, '');

The sitemap stayed hand written for the same reason. The official integration publishes sitemap-index.xml, and Search Console already knows this site’s sitemap as /sitemap.xml. Writing it myself also lets each entry carry a real lastmod taken from the git history rather than the build date, which is the more honest answer to when a page last changed.

What did not move at all

The content did not. Posts and projects are the same Markdown files with the same front matter. The data/ directory is byte for byte the same YAML and JSON. The Python scripts that refresh publications from ORCID, repositories from GitHub and book covers from Open Library were not touched, because they write JSON and do not care who reads it.

That is the part worth stealing. The migration was survivable because the content and the data were never coupled to the tool. Only the layer that renders them was.

GitHub Actions

The only deploy button, and it is a git push.

The workflow kept its most useful property too:

- name: Refresh auto-updating data
  # Non-fatal: on API failure the committed data/*.json is used as fallback
  continue-on-error: true
  run: python3 scripts/update_data.py

The generated JSON is committed. If ORCID is down the step goes red, the build carries on, and the site shows yesterday’s data. A stale publication list beats a failed deploy.

Islands, and the pages that ship nothing

Astro

A site framework that renders to HTML at build time and ships no client JavaScript unless a component asks for it. The interactive pieces are marked as islands and hydrate on their own; everything around them stays static. It also types the content: Markdown front matter and the data files are parsed through a schema during the build, so renaming a field is an error rather than a silently empty section.

The reason to be on Astro is that a page ships no JavaScript unless something on it needs JavaScript. The interactive parts are components marked as islands. Everything else is HTML produced at build time and then left alone.

So the photo wall, the chat panel, the tool filters and the search dialog hydrate. The about page, the posts and the project pages ship markup and CSS and nothing else. It is the component model without the tax I expected to pay for it.

The clearest measurement of that is the diagram library, further down.

The data files got a type, and immediately caught me

Every file under data/ is now parsed through a Zod schema at build time. Renaming a key is a build error instead of a blank section, which is exactly what I wanted.

It also introduced a new way to be wrong, and I walked into it within a week. A tool card silently lost its link. No error, no warning, a green build. The schema declared the field as internal, the JSON called it internal_url, and Zod strips keys it does not recognise without saying anything. The value was read, discarded, and rendered as nothing.

That is the shape of most bugs I hit in this migration, and it deserves a name: a wrong name produces nothing, not an error. A CSS custom property that does not exist, a Tailwind class whose token is not defined, a schema key that does not match the data. All three compile. All three render silence. None appear in a console. The only thing that catches them is reading the computed value in the browser.

Social cards, drawn differently

Every page still gets its own 1200x630 preview image, still drawn during the build, still without a headless browser taking screenshots.

Hugo did it with images.Text, stamping the title onto a base image. That function does not wrap, so the template split the title into lines by hand, and the variable font rendered at its lightest weight, so I drew the text three times at one pixel offsets to fake a bold.

Astro has no equivalent, so the card is now laid out with Satori, which takes something shaped like JSX and produces SVG, then rasterised with resvg. Text wraps because flexbox wraps, and both hacks above disappeared. The cost is a real dependency where there had been a built in function.

Search did not change

Pagefind still reads the finished HTML after the build and writes an index next to it. It never cared which generator produced the HTML, which is precisely why it survived the move untouched.

One attribute still decides what is indexed, and it is also how the pages I keep out of search stay out of it:

data-pagefind-body={noindex ? undefined : true}

Press cmd+K anywhere on the site and it opens.

Code blocks, and a bug that was invisible by design

Astro highlights code with Shiki at build time, so no highlighting library reaches the browser. Configure two themes and it writes both into the markup: the light one as inline styles, the dark one as CSS custom properties.

I configured both, opened a post in dark mode, and the code was still light. There was nothing to debug. Forty two code blocks, two hundred and sixty three --shiki-dark variables sitting in the HTML, and not one rule reading them. Emitting the variables is Shiki’s half of the job. Consuming them is yours:

html.dark .astro-code,
html.dark .astro-code span {
  color: var(--shiki-dark) !important;
}

Two details are specific to Astro. The class is .astro-code, not Shiki’s .shiki, so every example you find online needs renaming. And the switch has to key off whatever your theme toggle sets rather than prefers-color-scheme, or the blocks follow the operating system while the rest of the page follows the button.

Diagrams, three times

The single architecture diagram on this site has now been rendered three different ways.

Under Hugo it pulled Mermaid from a CDN on every page carrying the shortcode. During the migration I stubbed the renderer to return nothing, meaning to come back to it, and the diagram shipped as preformatted text for weeks. The stub was one line and it never complained.

It now uses an integration that renders in the browser, which sounds like a step backwards until you check where the cost lands:

mermaid({ theme: 'neutral', autoTheme: true }),

I watched the network on three pages. The privacy page and a blog post make zero requests for Mermaid. The one project page holding a diagram makes one. Ninety six kilobytes, paid by the single page that needs it.

The build got slower for it, from about ten seconds to thirteen. For one diagram that is the right trade only because the alternative, rendering to SVG during the build, needs a headless browser installed in CI. If this site ever has twenty diagrams, that calculation flips.

A full copy of the site, before it is the site

Cloudflare Pages

Static hosting on Cloudflare’s edge, used here for staging rather than production. Every push to a branch that is not master builds the whole site and publishes it to a separate hostname, so a redesign can be read on a real phone over real HTTPS instead of on localhost. The preview is served with a noindex header so it never competes with the live site in search results.

A rewrite lives on a branch for weeks, and reading it on localhost is not the same as reading it on a phone, on a real connection, over real HTTPS. Half the layout bugs in this migration only appeared once the site was somewhere I could open from the sofa.

So production stays on GitHub Pages and every push to a branch that is not master builds the whole site and publishes it to a separate host:

on:
  push:
    branches-ignore: ["master"]

The part I would not skip is the one line that keeps a full duplicate of the site out of search results:

printf '/*\n  X-Robots-Tag: noindex, nofollow\n' > dist/_headers

Without it you have published a second copy of every page, with the same text and different URLs, and invited a search engine to choose between them. The header is unconditional, so there is no configuration to get wrong later.

The workflow also checks its credentials before it builds rather than after, so a missing secret fails in a few seconds instead of at the end of a full build. That one is a small thing that pays for itself the first time it happens.

What Copilot was good for

GitHub Copilot

Used the way it is good: a fast first draft on code I can review, never on decisions I have not made yet.

A migration is mostly translation, and translation is what these tools are genuinely good at. Turning a Go template into an Astro component is mechanical work with a right answer, and having it drafted saved real hours.

Where it helped least is exactly where this post keeps landing. It writes code that compiles and renders nothing, for the same reason I do: the wrong token name and the right token name look equally plausible. All three of the silent bugs above survived review, mine and its, and died to a measurement in the browser.

GitHub

Version control is the least interesting part.

The crosspost that cannot duplicate itself

A post opts in to dev.to with one line of front matter:

devto: true

A script renders it, rewrites the components into portable Markdown and sends it with a canonical_url pointing back here, so the copy never competes with the original in search results.

The hard part was running it from CI, where there is no local state. Before sending anything the script asks dev.to what the account already holds and indexes it by canonical URL, so an article that exists is updated and never created twice, even on a fresh runner. The state file is only a cache plus a hash of the Markdown last sent, which is what stops an unchanged post being pushed back to the top of the feed on every deploy.

Two things bit me there. dev.to renders Markdown with hard wrapping on, so my eighty column source lines each became a <br>, which is why paragraphs are unwrapped before they are sent. And the API returns 429 well before its documented limits, so writes are spaced out and retried on the Retry-After header.

If you are reading this on dev.to, that is how it got here, and this is the second version of it.

Was it worth it

For the pages, no. Hugo rendered them faster and no reader can tell the difference.

For everything else, yes. The interactive parts are components with props instead of scripts hunting for selectors, the data files have a type, and the things that were held together by convention now fail at build time when I break them. That is what I actually bought, and it is worth thirteen seconds.

What I would tell myself before starting: pin the URLs first and treat any change to one as a bug, keep the content and the data independent of whatever renders them, and do not trust a green build. Three of the bugs in this post produced no error at all.

Last updated 5 September 2026

  • Share