Build notes from a single Saturday session on 2026-05-09, working on the bal-hub aggregator that fronts our 119-site micro-SaaS fleet. The session ran about four hours, all inside one repository, with deploy deliberately deferred to a later session. This post records three changes alongside the reasoning behind them. The reason I keep these notes long and public is that, for a solo operator, "what did I decide today and why?" is the single most valuable artefact for not re-deciding the same thing six months later. The post is written for the operator's future self, but kept public on the chance it helps another solo builder facing similar choices.
Change 1 — Home search bar
Why now
Users landing on / had no way to quickly find one tool out of 119. The previous UI was a hub-by-hub grid: even if a user already knew the tool's name, they had to remember which hub it lived under and click in. GA4's click-path analysis showed an average of 2.4 clicks from home arrival to tool detail. The goal was to compress that to 1 click.
The 2.4 average hides a worse truth. The click-depth distribution stretches from 1.0 (direct card click) all the way past 5.0 (hub → subcategory → backtrack → search → result), and users who gave up before reaching any tool are recorded in GA4 only as bounces, not as deep paths. Real user friction is almost certainly worse than 2.4. Cutting average click depth toward 1 is not just a UX polish — it is a bounce-rate reduction as well.
Decision — client-side instant matching
A server-side search index was overkill. The combined metadata of all 119 tools (name, slug, description, hubId, tags) totals under 100 KB, which is small enough to do in-memory with Array.filter and token matching. All data is baked into the static build, so response time is effectively instant.
```tsx
const matched = useMemo(() => {
if (!q.trim()) return [];
const tokens = q.toLowerCase().split(/\s+/).filter(Boolean);
return TOOLS.filter((t) => {
const haystack = [
t.name[locale], t.slug, t.description?.[locale] ?? '', t.hubId,
...(t.tags?.[locale] ?? []),
].join(' ').toLowerCase();
return tokens.every((tok) => haystack.includes(tok));
}).slice(0, 12);
}, [q, locale]);
```
Three policy decisions:
- AND matching across tokens. OR matching produced noisy results: a query like "vaccination calendar" returned every tool that mentioned vaccination alone, which is not helpful.
- Top-12 cap. Anything beyond twelve results goes unread anyway. Twelve maps to roughly 1.5 desktop scrolls and 2.5 mobile scrolls — the cutoff where users stop looking.
- Substring matching with
includes. This is significantly more forgiving thanstartsWithfor Korean queries, where users often type partial morphemes and expect the result to still appear.
Korean search has different ergonomics
Korean queries behave differently from English ones. A user typing the word for "vaccination" will see partial input ("예방접") well before the full word, and the search must still produce results. includes handles this naturally. Korean also lacks clean word boundaries without morphological analysis, but the tool names and tags are already curated into Korean-familiar phrasing, so a simple substring match suffices. We deliberately skipped morpheme analysis because it was unnecessary at this scale.
Keyboard behaviour
The implementation does not go as far as a full ARIA combobox pattern. It is a plain input plus a result list. Up/down arrow navigation and Enter to select are added. On mobile, the search bar is not sticky — it sits at the top of the page. A sticky search bar tends to be more annoying than useful on first visit, particularly on mobile landscape where it covers the result region. This was learned the hard way on a different tool.
Change 2 — Card priority moved to a data file
The problem
Card order on the home grid was previously determined by the array order of tools.ts. That meant the operator had to manually re-sort the source array to change visibility, and there was no recorded reason for any given ordering. When yebang had a 138-UV spike on 7 May, it was hard to lift it to the top fast enough.
A subtler problem: in six months, even the operator forgets why a particular tool sits at a particular position. The reasoning needs to live in a data file next to the value. A plain array order has nowhere to store the reasoning.
Decision — extract to its own JSON file
A new src/data/card-priority.json maps tool slug to a 0–100 priority score. The score bands are deliberately simple:
- 90–100 — top 5 by daily average UV in the trailing 30 days
- 70–89 — recently launched or experienced a viral spike in the trailing 30 days
- 50–69 — stable, steady search inflow
- 30–49 — below average but useful for category integrity
- 0–29 — actively deprioritised (back list)
Card sorting on both HomePage and HubPage collapses to one line:
```ts
const sortedTools = [...tools].sort(
(a, b) => (priority[b.slug] ?? 50) - (priority[a.slug] ?? 50)
);
```
The default of 50 is chosen so that new tools without an explicit score land in the middle of the grid automatically. To remove a tool from visibility, the operator must intentionally set it to 0. New tools, in other words, are added to the middle shelf by default and rise or fall as GA4 data accumulates.
Operations
Card priority JSON refreshes once a week, on Sunday morning, using GA4 + Search Console signals. Manual refresh is the point. Automating this would let one-day noise pull priorities around. A human checkpoint — "is this a real signal or a one-day blip?" — is what keeps the ranking stable.
The trade-off between automation and manual review was consciously chosen toward manual. Updating one JSON file takes five minutes per week. At 119 sites the human still beats the algorithm on signal-to-noise. If the fleet ever reaches 500 sites, automation becomes necessary; until then, manual is the right call.
Change 3 — AdSense review preparation
Background
Last week's AdSense review flagged a few of our sites for "low-value content." The aggregator itself already has enough content, so this was not a content-volume problem. It was a pattern uniformity problem. All 24 of our existing blog posts and guides used the same structure: lead → comparison H2 → case section → conclusion → related tools. Too consistent, easy to misread as programmatic output.
AdSense does not publish exactly which signals it uses, but a year of operational data suggests the following look like human signals:
- Tone and structure varying across posts (uniformity reads as autogeneration)
- First-person-only details (dates, specific numbers, failure stories)
- Cumulative short operational notes dated daily (signals a live, daily-run site)
- Consistent author photo or name usage (deferred for now)
This change addresses the first three.
Decision — five blog posts in different formats plus a new /log page
The five new posts deliberately use five different formats:
- First-person year-one retrospective (
microsaas-119-1year-retrospective) - Twelve-question Q&A (
microsaas-faq-2026-readers-asked) - Data analysis (5-year search trends,
korea-microsaas-search-trends-2026) - Self-interview (
solo-dev-self-interview-2026) - Build notes (this post)
Each post intentionally breaks the previous pattern. Heading-dense vs paragraph-dense. Q→A loops vs tabular analysis. Code snippets vs no code snippets. Adding five more 4,000-character H2-H3 trees in identical structure would have done less for AdSense than adding five intentionally varied formats.
In addition, a new /log page now collects short 50–300-character operational notes in reverse chronological order. These notes are not optimised for SEO — their purpose is to signal "a human runs this site every day." The first 24 entries are already written.
Code changes
src/data/posts.ts— added 5 blog postssrc/pages/LogPage.tsx— new pagesrc/data/log-entries.ts— operational note datasrc/App.tsx— added/logand/en/logroutesscripts/routes.mjs— added'log'to the prerenderstaticPathsarray so the route bakes intodist/log/index.html
One precaution carried over from previous incidents: the puppeteer prerender step blocks AdSense, GoogleAds, doubleclick, and analytics requests. Without these blocks, a stale show_ads_impl.js can get frozen into the dist HTML and cause 404s plus page-load delays — we hit this across 101 sites simultaneously once, which is one of those operator memories that turns into permanent muscle reflex.
Post-change verification
npx tsc --noEmit— passed- Local
npm run dev— search bar, card sort order, and new routes all behave correctly - Prerender dry-run — route count +6 (5 blog posts + 1 log page, KR + EN combined)
- Manual checks on Korean substring matching, AND token logic, arrow-key navigation, Enter-to-select all passed
- Card-priority JSON default of 50 verified — new tools appear in the middle of the grid as expected
Next session
Deploy is deferred. Code-only commit today. The next session has three planned items:
- AdSense slot audit across the fleet. Some sites among the 119 may have zero
<ins>slots actually present in the rendered HTML. AdSense approval is meaningless if no slots exist, and relying on auto-ads alone delays indexing. Highest priority. - Search-bar rollout to the top 5 sites. Currently only the hub has the new search experience.
/logautomation. The goal is for a Threads post to automatically flow into the log page, removing one manual step per day.
Notes to a future version of myself
Three questions I want this version of the post to be able to answer one year from now:
- Did the average click depth actually drop from 2.4 to 1?
- Is the manual weekly refresh of
card-priority.jsonstill in place, or did it migrate to automation? - Was the
/logpage actually a decisive factor in AdSense approval, or was the deciding factor something else entirely?
Related reading
- Building 100+ Micro-SaaS Tools — 2026 Retrospective
- Solo, 119 micro-sites, 1 year — honest retrospective
- 12 reader questions, Spring 2026
- SERP & Meta Audit
---
These notes are written primarily for the operator's future self, but kept public in case another solo builder finds them useful. Shorter day-by-day entries also appear on /log.