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 we 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.
We 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 we tagged something "React," we meant it. Body text gets 1x; it's signal, but noisy signal.
extractField handles the type mismatch between MiniSearch (expects strings) and our 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, we needed cmdk to display results in the order we 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: we are deliberately bypassing filtering, not accidentally omitting it.
Match Highlighting
MiniSearch returns a match object per result that maps fields to the terms that matched. We 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. We 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
We didn't think of the boost map as tuning; it was a statement about what matters. title: 5 says "what we named it matters more than where we mentioned it." That paid off when we added tag discovery pages. Because tags already carried 3.5x weight, search was surfacing the right results before we even built the routes. The ranking policy came first; the feature just followed it.