Skip to main content

Upgrading cmdk Search with MiniSearch Field Boosting

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

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.