Skip to main content

Upgrading cmdk Search with MiniSearch Field Boosting

Search results with ranked filters on a screen
Apr 10, 20262 min readReact, Next.js, Search, Performance, TypeScript

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.ts

The 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/tags and /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.