The Ranking Problem
The previous search implementation worked fine: 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 cracks showed when I added tag discovery. Searching "React" matched every entry that mentioned React anywhere in its excerpt, so a blog post titled "React" and a logistics case study that happened to mention React in passing ranked identically. cmdk's filter treats all the text as one opaque string; it has no concept of fields or weights.
I wanted ranked results without giving up cmdk's keyboard navigation, grouping, or accessibility, so the fix was surgical: keep cmdk as the UI shell and swap out its search brain.
MiniSearch as a Ranking Policy
MiniSearch is a 7KB full-text search library that runs entirely in the browser, and the configuration is where all the 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 really a content strategy in disguise. 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're deliberate categorization: if I tagged something "React," I meant it. Body text gets 1x; it's signal, but it's noisy signal.
extractField handles the type mismatch between MiniSearch, which expects strings, and the data, where tags is a string[]. Joining the tags with spaces turns ['React', 'TypeScript'] into one searchable field.
Disabling cmdk's Filter
cmdk filters results internally by default, but with MiniSearch handling the ranking, I needed cmdk to display results in the order I hand it:
<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 reads clearer in code review: it says I'm deliberately bypassing filtering, not accidentally forgetting 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 the text on matched terms and wraps the hits in <mark> elements. Searching "accessibility" highlights the word in both the title and the excerpt of matching results, so users get immediate visual confirmation of why something ranked where it did.
The Build-Time Index
MiniSearch gets a lot better with a body field for full-text search, but MDX content isn't available at runtime without rendering it. So I added a build-time generator that parses the MDX files, strips the 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 then merges that with a handful of static page entries (About, Services, Audit) that don't have MDX files behind them.
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 never thought 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 happened to mention it," and that paid off when I added tag discovery pages. Because tags already carried 3.5x weight, search was surfacing the right results before I'd even built the routes. The ranking policy came first and the feature just fell out of it.
