Skip to content
Consultation

Semantic Related, TLDR Boxes, Live Search

Adityo Guni Waluyo

Tag-matched related articles kept missing the point. Swap in cosine similarity, add a TLDR box, and make search live, all fail-safe against an old API.

The related articles section at the bottom of every blog post had been bugging me for months. Not because it was broken. It technically worked fine. But I opened a random article about deploying edge functions and the "related" box showed three posts that shared the same deploy tag but had nothing to do with edge computing. One was about database migrations. Another was a CSS grid tutorial that happened to mention deploying to Vercel.

My first instinct was to add more tags. Maybe the taxonomy was too coarse. If I tagged things more precisely, the overlap would shrink, and results would improve. So I started mentally mapping out a richer tag schema like deploy-edge, deploy-static, deploy-serverless, and immediately hit the wall. Tags are categorical. They don't capture degrees of similarity. An article about Python vector stores and an article about MariaDB full-text search both deal with "search" but in completely different ways. Tags can't tell you that.

What I actually needed was something that understood the content itself, not just the labels I'd manually slapped on it.

The Semantic Switch

The backend was already half-built. I'd written a Python service that computes cosine similarity from bge-m3 embeddings and exposes a /related endpoint. Twins from translation_group get excluded server-side so the Indonesian and English versions of the same post don't show up as "related" to each other. That part was solid. What was missing was the frontend actually calling it.

The commit adds fetchRelated into the existing Promise.all in app/[locale]/blog/[slug]/page.tsx. Previously that block ran three fetches in parallel: fetchNeighbors, fetchTaxonomyCounts, and a tag-based fetchArticles for the related section. Now there's a fourth. The critical detail: the semantic result is null-safe. When the endpoint is down, or running on an older API deployment that doesn't have it yet, fetchRelated returns null. The page falls back to the old tag-based list. Zero downtime, zero breakage.

The semantic endpoint returns lightweight items: just slug, title, excerpt, and a similarity score. That's intentional. The frontend still needs dates and tags to build the RelatedArticles timeline properly, so each item triggers a secondary fetch to get full article metadata. It caps at 8 results. No infinite scroll, no "load more", just a curated slice ranked by actual content similarity.

TL;DR Before the Body

Articles on the blog can carry a tldr string in their meta, produced alongside the embedding at ingest time. New ArticleTldr.tsx renders it as a box above the article body when it exists: a section with an aria-label, a primary-colored border, the short summary in slightly muted text. Simple component, no state, no effects.

It sounds small, but the change in reading experience was noticeable. Longer posts especially benefit. You can scan the TL;DR in five seconds and decide whether the long read is worth your time right now. I've seen blogs that put summaries at the bottom, but that defeats the purpose. By the time you reach it, you've already read the thing.

Search That Doesn't Wait for Enter

SearchDialog.tsx had a basic input that fired a search on form submit. Type your query, press Enter, see results. Standard pattern. But it always felt sluggish: type something, wait, get results back, realize you meant something slightly different, backspace, retype, Enter again.

The new version is live. Debounced input triggers a fetch as you type. Under the hood, useDeferredValue keeps the UI responsive while the search runs, and useTransition marks the result update as non-urgent so typing never stutters. Results appear progressively; the dialog doesn't flash a loading spinner and then replace everything at once. Each keystroke refines the list in near-real-time.

None of this is revolutionary. React has had these hooks for a while now [3]. But the combination of debounce + deferred value + transition in a search dialog is one of those patterns that feels obvious after you've built it and mysterious before. The lazy approach would've been to slap a setTimeout debounce on the input and call it done. That works, but the UI locks up briefly on each update because React treats the state change as urgent. useDeferredValue solves exactly that: it lets React know the search results can wait a beat while you type the next character.

What Holds Together

The pattern across all three changes is the same: each one fails safely. Semantic search returns null? You get the old tag-based list. A post has no tldr in its meta? The component just doesn't render. Search API is slow? The input stays responsive, results trickle in when they're ready.

No single feature depends on the other two. You could deploy any one of them without the others and nothing breaks. That's the kind of coupling I want, or rather the lack of it.

The commit is three distinct frontend features landing together because they all shipped in the same weekend. Conceptually they're unrelated: semantic similarity is a backend-driven improvement, the TL;DR box is pure presentational, and live search is a UX refinement. But they share a philosophy: don't block the experience while you make it better.

Right now the semantic list is primary and the tag-based list survives as the safety net, and I plan to keep it that way. The next honest step is watching which related links people actually click before trusting the scores completely.

Related reading: the old parallel-fetch related flow, the Python vector store behind the /related endpoint, and the MariaDB search API this dialog calls.

Sources

Related articles