Some of the projects I work on belong to clients. Others involve code that makes no sense to publish because it is part of the product itself. In almost all of them what I did stays closed, and from the outside all that is left is a landing page or a line on a CV. What I want to keep here is what none of that shows: why I made each decision, which problems I ran into, and what I weighed before choosing a path.
That is why I built How I Build. The first project I decided to document was the site itself, because I wanted to test the idea on something I could show in full, including the code. The repository holds the template that gives the site its structure together with the content I write about the projects. I kept the two parts separate so the template never needs to know anything about me or about the content published on it. Most of the decisions below start from that separation.
One repository, two branches
The template and the site content have to evolve together, but they should not be mixed. I wanted to use the same repository to develop the template without putting my content inside it, and at the same time keep the site I publish here up to date with those changes.
Decision
I kept one repository and two branches. main holds the template and site
holds the content. A workflow merges main → site on every push, keeping the
site current without repeating the changes by hand.
Trade-offs
- Every template change goes through two merges before it reaches production.
- Files both branches need to change, such as the config and the content index, can conflict. In those cases
sitealways wins. - Anyone cloning the template also gets a second branch they may not need. That is why the workflow simply does nothing when that branch is missing.
The default language has no prefix
I wanted English to be the default language of the site, but without putting
/en in every URL. At the same time, Portuguese had to stay available on a
path of its own. That left two alternatives: prefix both languages, or make no
distinction between them in the URL. The first ruled out using / for English;
the second created problems with indexing, sharing and static page generation.
Decision
English answers at /projects, Portuguese at /pt/projects. /en/* redirects
to the path without the prefix, so each page has exactly one address. Released
in v0.4.0.
Trade-offs
- The proxy is more complex than a plain redirect: rewrite, canonical redirect and preference read only at the root
- Building a link needs a helper instead of concatenating a string
- Reader preference applies only at
/. A shared deep link opens in the language it was shared in; that is deliberate, but it can surprise people
The theme is applied before the page paints
The theme had to be correct before the page first painted. Reading the cookie on the server looked like the natural solution, but it created two problems: the first visit has no cookie, so a reader who prefers the dark theme could see a white flash, and reading the cookie also took the routes out of static rendering.
Decision
Trade-offs
- An inline script in the document, plus
suppressHydrationWarningon<html> next/scriptdoes not serve here: it defers inline content until after the first paint, which is exactly what this solution needs to avoidaria-pressedonly becomes correct during hydration, because the server genuinely does not know the reader's preference
No cookie banner
The site stores two things: a cookie with the chosen language and a localStorage entry with the chosen theme. Both are written only when the reader uses the control that sets them.
That led me to a simple question: what exactly would the reader be refusing? If they choose Portuguese, for example, I need to store that choice to remember it later. If they refuse that storage, the feature they just asked for stops working.
Decision
No banner. A privacy page instead: what is stored, why and how to clear it. Released in v0.16.0.
Trade-offs
- Adding analytics later means adding the banner and rewriting that page
- Someone expecting a banner may read its absence as an oversight rather than a decision
A workflow that never ran
The workflow that merges main into site was written to trigger on release: published. It never ran once. It did not fail — it never started. There was no red run and no notification; the content branch simply fell behind.
GitHub does not start workflows from events created by GITHUB_TOKEN, and release-please publishes releases using that token.
The uncomfortable part is that I had already used this same property on purpose, one issue earlier, to stop the release workflow from looping on its own commit. The mechanism was familiar; I just applied it backwards.
Decision
Trigger on push to main, which is a human action. Fixed in v0.17.1.
Trade-offs
- The sync now runs on every merge, not on every release. That happens more often than originally designed, but it keeps the branches closer.
- release-please's own commit still does not trigger the workflow, so a version bump waits for the next merge.
The blog lives inside the same site
Not everything I want to write is a case study. There are opinions, there is something new I tried and wanted to record, and there are short notes that cannot carry a whole write-up of decisions. I wanted a place of my own for that, and the obvious way out was to open a separate blog on some ready-made platform, leaving this site to the projects alone.
Decision
Trade-offs
- A post only reaches the site once it exists in Portuguese and in English. The build refuses a text that has a single language, which protects the case studies and creates friction exactly on the short note.
- Tags are written per language, so
/blog/tags/processoand/blog/tags/processare different pages. Switching language only finds the counterpart because it sits at the same index in both files, and the build is what keeps that true. - Seven releases in a row went to the blog before it held any writing of mine. What holds this decision up from here is writing, and that part does not depend on code.