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:
| Measure | Value |
|---|---|
| Index file, uncompressed | 2.9 MB |
| Index file, brotli | 610 KB |
| Time to first result on a Moto G Power profile | 1.4 s after opening |
| Build time added | 6 s |
The 1.4 seconds is download plus parse plus the first query, and on a slow phone it is noticeable.
Step 2: Exclude Pages That Do Not Belong in Search
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.
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
| Measure | Default | Tuned | Tool |
|---|---|---|---|
| Index, brotli | 610 KB | 240 KB | Build output, brotli -q 11 |
| Time to first result, Moto G Power profile | 1.4 s | 0.48 s | Chrome DevTools, throttled |
| Later queries | 12–25 ms | 6–14 ms | performance.now() |
| Top result correct, 10 test queries | 6 | 9 | Manual |
| Build time added | 6 s | 4 s | CI logs |
| Effect on LCP | none | none | Lighthouse, lazy loading |
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
searchblock fromthemeConfig; 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 should I use Algolia DocSearch instead of local search?
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.
Related
- Parent: Docs Frameworks: Docusaurus, Starlight and VitePress — search in each framework.
- VitePress for Library Documentation — the rest of a VitePress docs setup.
- Search Index Size Budgets for Large Docs — where client-side indexes stop working.
- Pagefind vs Lunr for Small Documentation Sites — the other client-side options.
- VitePress vs Starlight for Vue and Astro Teams — search is one of the differences.