The Ranking Problem
The previous search implementation worked: a flat array, cmdk's built-in filter, and substring matching across title, excerpt, and tags concatenated into a single value string. It covered 47 pages with zero API calls.
The problem surfaced when I added tag discovery. Searching "React" matched every entry that mentioned React anywhere in its excerpt. A blog post titled "React" and a logistics case study that happened to mention React in passing ranked identically. cmdk's filter treats all text as one opaque string; it has no concept of fields or weights.
I needed ranked results without replacing cmdk's keyboard navigation, grouping, or accessibility. The fix was surgical: keep cmdk as the UI shell, swap out its search brain.
MiniSearch as a Ranking Policy
MiniSearch is a 7KB full-text search library that runs entirely in the browser. The configuration is where ranking decisions live:
const ms = new MiniSearch<SearchEntry>({
fields: ['title', 'excerpt', 'tags', 'category', 'body'],
searchOptions: {
boost: { title: 5, tags: 3.5, excerpt: 2, category: 1.5, body: 1 },
fuzzy: 0.2,
prefix: true,
combineWith: 'OR',
},
extractField: (doc, fieldName) => {
if (fieldName === 'tags') return doc.tags.join(' ');
if (fieldName === 'category') return doc.category || '';
const value = doc[fieldName as keyof SearchEntry];
return typeof value === 'string' ? value : '';
},
});The boost map is an explicit content strategy. Title gets 5x weight because a post called "Removing focus-trap-react" should rank first when someone searches "focus trap." Tags get 3.5x because they represent deliberate categorization: if I tagged something "React," I meant it. Body text gets 1x; it's signal, but noisy signal.
extractField handles the type mismatch between MiniSearch (expects strings) and the data (tags is string[]). Joining tags with spaces turns ['React', 'TypeScript'] into a single searchable field.
Disabling cmdk's Filter
cmdk filters results internally by default. With MiniSearch handling ranking, I needed cmdk to display results in the order I provide them:
<Command filter={() => 1} shouldFilter={false}>Returning 1 from the filter function tells cmdk every item matches. shouldFilter={false} would also work, but the explicit filter return makes the intent clearer in code review: I am deliberately bypassing filtering, not accidentally omitting it.
Match Highlighting
MiniSearch returns a match object per result that maps fields to the terms that matched. I extract these into a simpler structure and use them for highlighting:
function searchWithHighlights(
engine: MiniSearch<SearchEntry>,
query: string,
entries: SearchEntry[]
) {
const results = engine.search(query);
return results.map(result => {
const entry = entries.find(e => e.id === result.id);
const matches: Record<string, string[]> = {};
Object.entries(result.match).forEach(([field, terms]) => {
matches[field] = Object.keys(terms as Record<string, boolean>);
});
return { ...entry, matches };
});
}The component splits text on matched terms and wraps hits in <mark> elements. Searching "accessibility" highlights the word in both the title and excerpt of matching results, giving users immediate visual confirmation of why something ranked.
The Build-Time Index
MiniSearch benefits from a body field for full-text search, but MDX content isn't available at runtime without rendering it. I added a build-time generator that parses MDX files, strips markup, and produces a static JSON index:
pnpm prebuild # runs scripts/generate-search-index.tsThe script walks data/content/, reads each .mdx file, extracts the metadata export and the prose body, and writes a searchIndex.json that gets imported at build time. The runtime buildSearchIndex() function merges this with a handful of static page entries (About, Services, Audit) that don't have MDX files.
What Changed
- Ranked results: a title match outranks a passing body mention
- Fuzzy matching: "accessibilty" (typo) still finds accessibility posts
- Prefix search: typing "type" matches "TypeScript" immediately
- Match highlighting: users see why each result ranked
- Tag discovery pages:
/blog/tagsand/blog/tags/[tag]built on the same index
The Principle
I didn't think of the boost map as tuning; it was a statement about what matters. title: 5 says "what I named it matters more than where I mentioned it." That paid off when I added tag discovery pages. Because tags already carried 3.5x weight, search was surfacing the right results before I even built the routes. The ranking policy came first; the feature just followed it.
