Every article on this site now ends with a “You might also like” block. The rendering was the easy part. The hard part was the question underneath it: what makes two posts related?
The obvious answer is shared tags. Two posts that both carry SEO and Observability are probably about adjacent things. So you score each pair by how many tags they have in common and show the top three. I built exactly that, and it was useless, because one tag defeats it.
The tag that means nothing
Seventy-three of the seventy-four posts carry Build Log. It is the house tag for “this is a post about building something,” which is almost everything I write. If shared tags decide relatedness, every post is related to every other post through Build Log, and the three cards you get are just the three most recent articles wearing a coat of false relevance.
The fix is the idea behind inverse document frequency. A tag’s signal is inversely proportional to how common it is. A tag on all seventy-four posts tells you nothing about any particular pair. A tag on three posts tells you those three belong together. So the scoring weights each shared tag by its rarity, and Build Log, being on nearly everything, is forced to zero weight. It stops voting. Rarer shared tags carry the pairing, and ties break by recency.
The result reads correctly. The article about six pages Google could not find now surfaces three cannibalisation pieces through the SEO and Observability overlap, which is exactly the neighbourhood a reader of that post would want next. It found them because the tags that are rare enough to mean something were the only ones allowed to speak.
When the honest answer is no block
Two of the seventy-four posts render no related block at all, and both are correct. One is a hidden post, suppressed on purpose. The other carries only tags that are unique across the whole corpus, so once the ubiquitous tags are silenced there is genuinely nothing related to it. The section could have padded that out with the nearest three by date. It hides instead. An empty answer is better than a confident wrong one, and a “related” block full of unrelated posts is worse than no block.
Two more defaults that were quietly wrong
The same change carried two other metadata fixes, because they lived on the same article page and shared the same shape: a sensible-looking default doing the wrong thing where you cannot see it.
The title template appends · Captain Random to every page title. That is fine at normal length. Google truncates a title near sixty characters, so a ninety-two-character article title was spending its final, cut-off characters on the brand suffix instead of on words that describe the post. Long titles now drop the suffix past a length budget and keep their own words in the visible span. Short titles keep the brand.
The descriptions had a subtler bug. I author curly quotes and dashes in frontmatter as named entities like “, and React was helpfully encoding the ampersand again on the way into the meta and Open Graph tags, so the served description carried “ blots where a quotation mark should be. Decoding the handful of entities I actually author, at load time, ships clean Unicode into the metadata instead of encoded rubble.
The thread
None of these three showed up on the page I was looking at. The article rendered fine. The bug was in the layer the page presents to something else: the related block a reader sees at the bottom, the title Google prints in the results, the description a link preview pulls. That layer is full of defaults that look reasonable and quietly misfire, and you do not catch them by reading your own article, because your own article is not where they appear. The tag that told me nothing was only visible once I asked it to tell me something.

