Adding Local Search to a VitePress Site

VitePress ships two search options: Algolia DocSearch, which needs an application and a crawler, and a local search provider built on MiniSearch that needs one line of configuration. For most library and product docs, local search is the right default — no account, no crawler schedule, no third-party request when a reader types, and results that match the deployed content exactly because the index is built with it.

This guide adds local search to a 640-page VitePress 1.5 documentation site for an open-source SDK, then tunes it: what gets indexed, how results rank, how large the index is, and what it costs readers on a phone. It belongs to Docs Frameworks: Docusaurus, Starlight and VitePress, where built-in search is one of the differences between the three frameworks.

Prerequisites

  • VitePress 1.x with a working .vitepress/config.mts.
  • Brotli or gzip compression on the host, which matters for the index download.
  • A list of ten or so real search queries to test against — from support tickets, a previous search provider's analytics, or the team's own guesses.

Step 1: Turn It On

// .vitepress/config.mts
import { defineConfig } from 'vitepress';
export default defineConfig({
  themeConfig: {
    search: { provider: 'local' },
  },
});

That is enough for a working search box with keyboard shortcut (/ or Ctrl+K), result highlighting and per-locale indexes. The build writes the index to .vitepress/dist/assets/chunks/@localSearchIndexroot.*.js, which is loaded when the search modal opens.

The first build measured:

MeasureValue
Index file, uncompressed2.9 MB
Index file, brotli610 KB
Time to first result on a Moto G Power profile1.4 s after opening
Build time added6 s

The 1.4 seconds is download plus parse plus the first query, and on a slow phone it is noticeable.

Sequence of loading VitePress local search Three actors, the reader, the page and the static host. The page loads with no search index. The reader presses Control K and the page opens the search modal. The page fetches the index chunk from the host. The host returns the compressed index. The page parses it and runs the first query, then later queries run in memory. When the index is downloaded Reader Page Static host HTML, CSS, JS (no index) Ctrl+K: open search fetch index chunk 610 KB brotli parse and first results
The index cost is paid on first open, not on page load, so it never affects LCP.

Not every page helps search. On this site, 310 of the 640 pages were generated API reference pages — one per class and method — whose text was mostly signatures. They dominated results for common words and made up 58% of the index. VitePress lets a page opt out with frontmatter, and the generator for the API pages added it:

---
search: false
---

For whole sections, _render can return an empty string for paths you want to exclude (shown in the next step). API reference remained searchable by symbol name through a separate small index, described below.

Step 3: Control What Text Is Indexed

By default VitePress indexes each page's rendered content, split by heading into sections. The _render hook lets you change what text goes into the index for each page — add keywords, drop boilerplate, or shorten long sections:

search: {
  provider: 'local',
  options: {
    async _render(src, env, md) {
      if (env.frontmatter?.search === false) return '';
      if (env.relativePath.startsWith('api/')) return '';
      let html = md.render(src, env);
      if (env.frontmatter?.keywords) html = `<p>${env.frontmatter.keywords}</p>` + html;
      return html.replace(/<div class="language-[\s\S]*?<\/div>/g, '');
    },
  },
},

The last line removes code blocks from the indexed text. Code blocks made up 38% of the remaining indexed text and almost never matched what readers searched for — they searched for "retry", not for RetryPolicy.exponential(3). Adding a keywords frontmatter field let writers attach terms readers use that the page does not, such as "backoff" on the retry guide.

Compressed search index size after each tuning step Paired bars of brotli-compressed index size. Excluding API reference pages took it from 610 to 330 kilobytes. Removing code blocks from indexed text took it from 330 to 240 kilobytes. The uncompressed size went from 2.9 megabytes to 1.1 megabytes overall. Index size after each change Before step After step Exclude API pages 610 KB 330 KB Drop code blocks 330 KB 240 KB
Measured as the size of the index chunk after brotli level 11, as served by the host.

Step 4: Tune Ranking

MiniSearch options pass through miniSearch.searchOptions. The defaults weight all fields equally and use a fuzzy distance that matched too loosely on short words: "auth" matched "path" and "author". Boosting titles and tightening fuzziness made the top result right for 9 of the 10 test queries, against 6 before:

options: {
  miniSearch: {
    searchOptions: {
      fuzzy: (term) => (term.length > 5 ? 0.2 : false),
      prefix: true,
      boost: { title: 4, titles: 2, text: 1 },
    },
  },
},

fuzzy as a function turns fuzzy matching off for short terms, where it causes most false matches. prefix: true keeps search-as-you-type working.

Step 5: A Separate Symbol Index for the API

Readers still needed to find RetryPolicy by name. Rather than put 310 API pages back into the main index, the API generator writes a small JSON file of symbol names, kinds and URLs (24 KB compressed), and a custom search slot in the theme shows symbol matches above the page results when the query looks like an identifier — a capitalised word or one containing a dot or parentheses. This is about 60 lines of Vue in a theme override, and it gives exact-match navigation for symbols without any fuzzy noise from their signatures.

Testing Search Quality Before Each Release

Search quality drifts as content changes: a new page with a long, keyword-heavy section can push the right answer down for a common query, and nobody notices until a reader complains. The site keeps its ten test queries, plus about twenty more collected since, in a JSON file with the expected top result for each, and a small Playwright test runs them against the built site:

for (const { q, expect: url } of queries) {
  await page.keyboard.press('Control+K');
  await page.getByRole('searchbox').fill(q);
  const first = page.locator('.VPLocalSearchBox .result').first();
  await expect(first).toHaveAttribute('href', new RegExp(url));
  await page.keyboard.press('Escape');
}

The test runs in about 14 seconds and fails the pull request when a query's top result changes. Most failures so far were intended — a better page for the query had been written — and were fixed by updating the expected URL in the same pull request. Two were real regressions, both from a page whose keywords field had been copied from another page.

The same file doubles as documentation of what readers search for. When the support team sees a new recurring question, they add the query and the page that answers it, and the test then protects that answer from future changes to ranking or content.

Measured Impact

MeasureDefaultTunedTool
Index, brotli610 KB240 KBBuild output, brotli -q 11
Time to first result, Moto G Power profile1.4 s0.48 sChrome DevTools, throttled
Later queries12–25 ms6–14 msperformance.now()
Top result correct, 10 test queries69Manual
Build time added6 s4 sCI logs
Effect on LCPnonenoneLighthouse, lazy loading
Milliseconds from opening search to first results on a throttled phone Vertical bars. The default index took 1,400 milliseconds to first result. After excluding API pages it took 780 milliseconds. After also removing code blocks it took 480 milliseconds. Time to first result after opening search 1400 ms Default 780 ms No API pages 480 ms No code blocks
Chrome DevTools with a Moto G Power profile and Fast 4G throttling, median of five runs, cold cache.

When to Move to Algolia

Local search fits this site comfortably. The point at which it stops fitting is the compressed index size readers will wait for: past about 1 MB compressed, the first query on a phone takes several seconds. Sites with thousands of prose pages, several languages in one index, or a need for query analytics should look at DocSearch, compared in Pagefind vs Algolia DocSearch. Pagefind, which splits its index into fragments and loads only what a query needs, is another option for large VitePress sites, at the cost of replacing the built-in modal.

Pitfalls & Rollback

  • Indexing generated reference pages. They crowd out guides; give them their own symbol search.
  • Indexing code blocks. They inflate the index and rarely match queries.
  • Loose fuzzy matching on short words. Turn fuzziness off below five or six characters.
  • No compression on the host. The index is plain JavaScript and compresses about 5×.
  • Rollback: remove the search block from themeConfig; nothing else depends on it.

Conclusion

VitePress local search is one line to enable and a few options to make good. Excluding generated pages, dropping code from the indexed text and tightening fuzzy matching took the 640-page SDK docs from a 610 KB index with six of ten good top results to 240 KB with nine of ten, and first results on a slow phone in under half a second.

FAQ

Does VitePress local search need a server or an external service?

No. VitePress builds a MiniSearch index at build time, split per locale, and the browser downloads it when the reader opens the search box. Everything runs on the client, so it works on any static host and offline once loaded.

How large does the VitePress search index get?

It grows with the amount of indexed text. On the 640-page site in this guide the default index was 2.9 MB uncompressed and 610 KB with brotli. Excluding generated API pages and limiting indexed content to headings and the first paragraphs of each section brought it to 1.1 MB and 240 KB compressed.

When the index would be too large to download comfortably, roughly beyond a few thousand pages of prose, or when you need analytics, typo tolerance tuned per language, or search across several sites. For small and medium documentation sites local search is simpler, free and private.

Does local search slow down page loads?

Not the initial load. The index is fetched only when the search box is opened, so it does not affect LCP or the first interaction. The first query after opening costs the download and parse time, which on the reduced index was about 180 milliseconds on a mid-range phone.

Filed underHow-tosVitePress