Software Engineering Portfolio CMS
A portfolio built to separate content from code, making projects and technical writing easier to maintain and publish through a CMS.

The problem
A portfolio that keeps its content in code makes every edit a deployment. Fixing one sentence in a case study means editing a file, committing it, and waiting for a build. I wanted to separate the content from the application so I could publish projects and journal entries without changing the code.
That mattered here because the portfolio itself is part of how I present my engineering work. It needs to be easy to maintain and keep accurate as the projects and writing change.
My approach
Three decisions shaped everything else.
I kept all content reads behind one boundary. The GROQ queries live in a single module, with each query carrying the cache tag it depends on, so pages don't have to know how Sanity is queried or how its data should be invalidated. This made the publishing path easier to reason about: the query and the cache it depends on stay together instead of being maintained in separate places.
I kept syntax highlighting on the server. Shiki highlights code while the page is rendered and sends the browser styled HTML, so there is no client-side highlighting code to download or execute. The alternative would add roughly 40–100 KB of JavaScript and introduce a flash of unstyled code for the same result.
I made publishing invalidate content by document type. When Sanity publishes a change, the webhook tells the application which type changed, and only that cache tag is revalidated. A journal update doesn't need to invalidate project content, and a project update doesn't need to touch the journal.
One more thing shaped this, and it wasn't architectural. I built this site with an AI assistant, which was the first time I had worked that way. It made me faster on the parts I already understood, and it made me confident about parts I hadn't checked. The second one is the problem. So I stopped treating what it produced as finished, and started going looking for what was wrong with it.
The outcome
The publishing workflow works: I can write or update content in Sanity and have the change reach the live site without a deployment.
But building the system also exposed a problem I hadn't expected. The sitemap had frozen at the previous deployment. Its lastmod still showed the build timestamp 62 hours later, which meant a project published during that period was missing from the sitemap even though it was already visible on /work.
I found the issue by checking the sitemap's own lastmod value rather than assuming the cache was behaving correctly. Two of my initial assumptions about where the stale response was coming from were wrong, so I traced the rendering path instead of trying another cache change.
The fix was to make the sitemap render dynamically. I then published a real change through Sanity and checked the result without redeploying the site. The updated project reached the sitemap in 36 seconds, while the deployment build fingerprint remained unchanged. That gave me evidence that the publishing path, not a new deployment, was responsible for the update.
I also found a smaller correctness issue in the structured data. The wordCount was being estimated from rounded reading time, so a 229-word post was being reported as 400 words. I replaced that with a real word count and made the page and query use the same definition.
Neither issue was visible in the UI. The sitemap still looked valid, and the incorrect word count didn't change what a reader saw. I found both by checking the parts of the system that aren't immediately visible.
I measured the site with Lighthouse on a production build, taking the median of three runs after discarding a warm-up. A single run is not worth much here: one case study run came back at 87 when the other two were both 93.
Performance came out at 88 on the home page, 92 on a journal post and 93 on a case study. Accessibility, best practices and SEO are 100 on all three, and cumulative layout shift is 0.000.
The home page misses the 90 I wanted, and it is worth saying why rather than rounding it up. Its largest contentful paint is the hero paragraph rather than the avatar, and almost all of that time is render delay rather than network, so the text is waiting on fonts. The page loads 168 KB of them, and 118 KB of that is the display face on its own, because it carries the optical size and wonk axes the headings actually use. Stripping those axes measures at 91, but it gives me a flatter face and a font binary in the repository that nobody could regenerate from the source I installed. I decided the typography was worth more than the three points.
