how-i-build

2026

how-i-build

This site is the first project documented with How I Build itself: a template, and a record of the decisions taken to build it

LivePublicSole authorNext.jsNext.jsTypeScriptTypeScriptBunBun

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 mainsite 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 site always 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

A synchronous script in <head>, before anything renders. No cookie on the server and no React state. The class on <html> is already correct before hydration, so the control does not need to start with a state and fix it afterwards. Released in v0.5.0 and extended to three options in v0.8.0.

Trade-offs

  • An inline script in the document, plus suppressHydrationWarning on <html>
  • next/script does not serve here: it defers inline content until after the first paint, which is exactly what this solution needs to avoid
  • aria-pressed only becomes correct during hydration, because the server genuinely does not know the reader's preference

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

The blog stayed here, in the same repository and on the same content model as the case studies: static imports, one file per language, and the same build checking both. It started in v0.20.0 and settled in v0.26.0, with an index, covers, tags, a monthly archive and search.

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/processo and /blog/tags/process are 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.

References