Search
Client-side search that works out of the box with no API keys, plus hosted and semantic adapters you can switch to as your docs grow.
Blume ships local search with no hosted infrastructure and no API keys. It runs in the browser, works in both blume dev and blume build, and indexes only your real content — navigation chrome and excluded pages are skipped. When you outgrow it, you can switch to a hosted or semantic backend without changing how search looks or behaves — only the adapter you pass to search changes.
Every backend is an adapter imported from blume/search: Orama (the default), FlexSearch, Pagefind, Algolia, Orama Cloud, Typesense, and Mixedbread. Each adapter owns its options, its runtime dependency, and the secrets it needs. Orama and Pagefind ship with Blume; every other adapter’s SDK is an optional peer dependency that you install yourself, so picking one backend never pulls in the others. If it’s missing, blume build stops before Vite runs, naming the package and the command that installs it, and blume doctor reports it too.
import { defineConfig } from "blume";
import { algolia } from "blume/search";
export default defineConfig({
search: algolia({
appId: "YOUR_APP_ID",
apiKey: "YOUR_SEARCH_ONLY_KEY",
indexName: "docs",
}),
});
An adapter is a plain description of the backend — not a live client — so Blume can inline it into the generated site and into an ejected project. Pass the adapter directly, or use the object form when you also want to set the popular links, indexing, or analytics options beside it:
import { pagefind } from "blume/search";
search: {
provider: pagefind(),
popular: [{ href: "/guides/getting-started", icon: "rocket", label: "Getting started" }],
indexing: { includeCodeBlocks: true },
},
Using search
Open search with the search icon in the header, with ⌘K (or Ctrl K), or by pressing / when you’re not typing in a field. Esc closes it, and ⌘J (or Ctrl J) toggles the result preview pane.
Queries match page titles, descriptions, and body text, with title matches ranked highest and descriptions above body.
Popular pages
Before a reader types a query, the search dialog shows a Popular list. By default it is the first six sidebar pages — which on multi-tab sites often surfaces the wrong section. Pin the links you want instead:
search: {
popular: [
{ href: "/guides/getting-started", icon: "rocket", label: "Getting started" },
{ href: "/guides/install", icon: "download", label: "Install" },
{ href: "/concepts/overview", label: "Overview" },
],
},
Each entry takes an href (internal route or external URL) and a label, plus an optional icon — a built-in icon name, image path/URL, or inline SVG (same inputs as nav icons), defaulting to a file glyph. Omit popular or leave it empty to keep the sidebar fallback. Leaving provider out of the object form keeps the default Orama adapter.
Write href as if the site were mounted at the root — a basePath is applied for you, the same as navigation.featured. External URLs pass through untouched.
What’s indexed
For Orama, FlexSearch, Algolia, Orama Cloud, and Typesense — and the MCP server’s search_docs tool — Blume indexes each page’s title, description, and body reduced to plain text: code blocks, images, and markup are stripped, so results stay relevant. These indexes are built from your source files, so they’re identical in dev and production. Pagefind indexes the built HTML instead, and Mixedbread syncs your raw Markdown, so both always search code.
If your docs rely on code examples for searchable terms like options, methods, or error names, opt fenced code into the source-built indexes:
search: {
indexing: {
includeCodeBlocks: true,
},
},
Each fence’s body and title (blume.config.ts above) become searchable; the language and fence markers don’t. On .mdx pages the index reads components as the text they show — a Card’s title, a Tab’s label, a TypeTable’s descriptions — using the same serializers as the agent surfaces, so an agents.markdownComponents entry covers your own components too. The option has no effect on Pagefind or Mixedbread. Expect the index to grow with your fenced content — the client index ships to every reader, hosted adapters store more (the Algolia sync splits a long page across several records), and a hit inside a fence shows flattened code in the result excerpt.
On a versioned site, results default to the version being viewed, with an “All versions” toggle in the dialog footer (remembered per reader). Cross-version hits name their version on the row. Orama, FlexSearch, Algolia, and Typesense honor the scoping — hosted records carry a version facet, with the current docs uploaded as "current". Pagefind, Orama Cloud, and Mixedbread don’t scope by version: their results span every version, and the dialog leaves out the toggle.
On a site with i18n, results default to the language of the page being viewed, with an “All languages” toggle beside it (also remembered per reader). Orama and FlexSearch filter their index in the browser, and Algolia, Orama Cloud, and Typesense filter on the locale each record carries. Pagefind keeps an index per language: it searches the page’s own, and with “All languages” it merges in the others, which it then searches with the page language’s word stemming. Mixedbread doesn’t scope by language: its results span every language, and the dialog leaves out the toggle.
Tags
Add search.tags to a page’s frontmatter to label it for a hosted adapter. The page’s first tag is uploaded as a tag field on its record in Algolia, Orama Cloud, and Typesense, where you can filter on it in the provider’s dashboard or your own search UI. Typesense declares tag as a facet; on Algolia, add it to the index’s attributesForFaceting yourself, and the sync keeps it there.
search:
tags: [api, reference]
The search dialog doesn’t read tags. Its filters group results by the sidebar section each page is in, and they show with Orama and FlexSearch, whose indexes carry each page’s section.
Ranking
Two frontmatter fields shape how a page ranks. search.keywords lists extra terms the page is found by, even when its text doesn’t use them. It suits a page readers search for by an old name, an acronym, or a competitor’s term. search.boost multiplies the page’s relevance: above 1 moves it up, below 1 moves it down.
search:
keywords: [install, setup, getting started]
boost: 3
Keep boosts to a few pages readers most often need, such as a quickstart or an overview, with values from 2 to 5. Use a value below 1, like 0.5, for a page that should rank under others that match as well, such as a legacy guide. Keep boosts under 10: past that a boost drowns out how well a page matches, and the page tops searches it barely matches.
How much a boost moves a page depends on the adapter:
- Orama (the default) and FlexSearch multiply every match by its page’s boost before sorting, so a boost of 3 counts exactly three times. The MCP server and the assistant’s search rank the same way.
- Pagefind weighs a boosted page’s text more heavily. It damps repeated matches, so the lift is smaller than the multiplier: a boost of 5 ranks the page higher, but closer to twice as high. Pagefind counts a boost over 10 as 10.
- Algolia sorts by boost among results that match about equally well, so a boost settles close calls without lifting a weaker match over a strong one. The sync adds
desc(boost)to the index’s custom ranking, after any ranking you set. - Typesense sorts by relevance, then by boost among results with the same relevance score, so a boost settles ties without lifting a weaker match over a strong one.
- Orama Cloud fetches a wider set of results and re-sorts them by relevance times boost.
- Mixedbread searches your own store, which Blume doesn’t upload to, so boosts and keywords don’t reach it.
Keywords are searched on every adapter except Mixedbread.
Search analytics
With an analytics adapter configured, search reports what readers look for. A query is recorded once it settles, after a second without typing or when the reader picks a result or closes the dialog, as a search event with the query, the number of results, and the path it was searched from. A query with no results shows up with results: 0, so filtering on it lists what your docs don’t cover yet. Picking a result sends a search_select event with the query, the result’s 1-based position, its url, and the path it was picked from, for click-through.
Queries are sent as typed, up to 100 characters. Your analytics sees them the way it sees any page’s search terms, so treat them as reader input: a reader can paste an email address or a key into the box, and Google Analytics and Plausible forbid sending personal data. To keep query text away from your providers, turn it off:
search: {
analytics: { queries: false },
},
The search and search_select events then carry queryChars, the query’s length, in place of query. The text travels only on the blume:track DOM event, as query in detail.props, so a listener you write can forward it wherever you decide it belongs, the way the assistant handles questions. With cookie consent on, only readers who allowed analytics are counted.
Adapters
The client-side adapters are keyless and need no options — their clients are configured from i18n (or, for Pagefind, from the built HTML), so an unknown key is rejected by config validation rather than ignored. The hosted ones take public credentials (safe to ship to the browser) and read their secret admin key from an environment variable at build time — the secret never lands in the config or the client bundle. Every option you pass to a hosted adapter is kept verbatim and handed to its search client in the browser (or, for Mixedbread, to the search endpoint), so an option Blume doesn’t name still reaches the SDK — Algolia’s liteClient, the Typesense Client, or OramaClient. Those options must be JSON values — the descriptor is inlined into the generated project as a literal, so a function, undefined, or a bigint fails config validation with a path instead of vanishing on the way.
Algolia, Orama Cloud, and Typesense upload your pages to their index at the end of each blume build. When the admin key isn’t set, the build warns and skips the upload, so a build without secrets, such as a local one, still works. When the key is set and the upload fails (a wrong key, an unreachable host, a rejected record), the build fails with BLUME_SEARCH_SYNC_FAILED instead of deploying against an index it didn’t update.
Orama (default)
Blume’s default engine. It builds a JSON index served at /blume-search.json and queries it in the browser — instant, client-side, and live in blume dev as you edit. No keys, no service. Omitting search entirely selects it; spell it out only when you want to be explicit:
import { orama } from "blume/search";
search: orama(),
Non-Latin scripts
The standard tokenizer keeps only Latin letters and digits, so text in any other script — Japanese, Chinese, Korean and Thai, but equally Russian, Greek, Hebrew and Hindi — would otherwise produce no matches at all. Blume handles this for you: pages in a locale that resolves to a non-Latin script are indexed with a word-segmenting tokenizer (built on the browser- and Node-native Intl.Segmenter). Declaring your site’s languages is all it takes:
i18n: {
defaultLocale: "ja",
locales: [{ code: "ja", label: "日本語" }],
}
The same tokenizers serve the search dialog, the MCP server’s search_docs tool, and assistant grounding. The script is what decides, not the language name — az-Cyrl is segmented while sr-Latn is not. With a non-Latin default, every page shares the default locale’s tokenizer: Latin words survive segmentation intact, so pages in English stay searchable alongside the default language. With a Latin default, Latin-script pages keep the standard tokenizer and each translation in a non-Latin script gets its own locale’s, so an English site’s Japanese and Hindi pages are searchable from their own language and from a search across every language. The standard tokenizer strips accents and spells out letters that have none to strip (ø as o, ł as l, ß as ss), so cafe finds café and lodz finds Łódź, and the search dialog and the assistant’s excerpts find the accented word in the page too. Where accents tell words apart (Swedish ändra and andra, Vietnamese tones), a query also matches words that differ from it only in those accents.
Japanese and Chinese go one step further. Segmenting alone indexes a compound term as its parts — 資金決済法 as 資金, 決済 and 法 — which lets a page mentioning each part somewhere outrank the page the term is actually about. Han, Hiragana and Katakana are therefore indexed as overlapping character pairs, and queries on those indexes prefer pages carrying a term’s pairs together, loosening to any-pair matching when no page carries them all, so typing a whole sentence still returns its closest pages. Korean and Thai keep their segmented words.
FlexSearch
A second keyless, client-side option (install flexsearch). It reuses the same /blume-search.json index Orama ships and builds a FlexSearch document index in the browser. Works in blume dev and blume build.
FlexSearch has no equivalent segmentation hook, so for sites in a non-Latin script prefer Orama (the default) or Pagefind, whose pagefind_extended binary indexes a broad set of languages and segments Chinese, Japanese and Korean natively.
import { flexsearch } from "blume/search";
search: flexsearch(),
Pagefind
For very large docs, opt into Pagefind. It indexes your built HTML and loads the index in shards on demand, keeping the initial payload tiny no matter how big the site grows.
import { pagefind } from "blume/search";
search: pagefind(),
Pagefind only runs during blume build, so search isn’t available in blume dev with this adapter. It indexes each docs page’s article — not the header, sidebar, or footer around it — so custom pages built on PageLayout, the 404 page, and the generated changelog index aren’t in its results.
Algolia
Install algoliasearch. The browser queries Algolia directly with your search-only key. Each blume build replaces the index using the admin key from ALGOLIA_ADMIN_API_KEY (the build warns and skips the upload if it’s unset). The whole index is replaced on every sync, so pages you delete or rename don’t linger as stale results. The sync also adds filterOnly(locale) and filterOnly(version) to the index’s attributesForFaceting, keeping any facets you declared yourself, because the dialog scopes results by language and docs version, and in Algolia a filter on an attribute that isn’t declared for faceting matches nothing.
Algolia caps a record at 10 KB on its Build and Grow plans, so the sync splits a longer page into several records, each carrying the page’s title, description, and URL with a stretch of its text. It then sets the index’s attributeForDistinct to url, unless you set your own, so a split page appears once in results, at its best-matching record.
import { algolia } from "blume/search";
search: algolia({
appId: "YOUR_APP_ID",
apiKey: "YOUR_SEARCH_ONLY_KEY", // public
indexName: "docs",
}),
Orama Cloud
Hosted Orama (install @oramacloud/client). The browser queries your index endpoint with the public API key; blume build pushes records to the index using ORAMA_PRIVATE_API_KEY. Set indexId to enable the sync; without it, the build warns and skips the sync even with the key set.
import { oramaCloud } from "blume/search";
search: oramaCloud({
endpoint: "https://cloud.orama.run/v1/indexes/your-index",
apiKey: "YOUR_PUBLIC_API_KEY",
indexId: "your-index-id", // for the build-time sync
}),
Typesense
Self-hosted or cloud Typesense. Install typesense. The browser queries the collection with the search-only key. Each blume build imports your pages into a new collection using TYPESENSE_ADMIN_API_KEY, then points an alias named after collection at it and drops the collection it replaced. Searches keep reading the previous collection until the new one is complete, and deleted or renamed pages don’t linger as stale results. If the sync fails, the build fails and the alias keeps serving the last complete sync. The admin key needs to manage collections and aliases. On the first sync, a collection that already has the collection name (from an earlier Blume version) is replaced by the alias, and search keys scoped to that name keep working. Settings you hand-tune on a collection don’t carry over to the next one, so reapply them after a build.
import { typesense } from "blume/search";
search: typesense({
host: "xyz.a1.typesense.net",
collection: "docs",
apiKey: "YOUR_SEARCH_ONLY_KEY", // public
// port + protocol default to 443 / https
}),
Typesense’s default tokenizer splits text on spaces, so in Chinese, Japanese, or Thai a query only matches from the start of a run of text. Set locale to your pages’ language as a two-letter code (locale: "ja", "zh", "th") and the sync tokenizes the searched fields for it; Latin-script words in those fields still match. One collection holds every language, so the setting applies to all of them.
Mixedbread
Semantic search via Mixedbread (install @mixedbread/sdk). Queries are proxied through a generated /api/search endpoint that holds your key, so this adapter requires server output: a host adapter such as deployment: vercel() from blume/deploy (see Server rendering). The endpoint reads MIXEDBREAD_API_KEY. Blume doesn’t upload to the store: sync your content folder with the Mixedbread CLI as part of your build, e.g. mxbai store sync <STORE_ID> ./docs --yes (the CLI reads its key from MXBAI_API_KEY).
Each result links to the page its file renders, found by the path the CLI records for the file, so run the sync from your project root or a folder above it, such as a monorepo root. A page appears once, at its best-matching passage, and passages from files that aren’t pages, or from pages excluded from search, are left out.
import { mixedbread } from "blume/search";
search: mixedbread({
storeId: "YOUR_STORE_ID",
}),
The search dialog waits for the reader to pause typing before it sends a query, and drops a query the reader has typed past. Each query sent counts toward the endpoint’s rate limit.
Disabling search
search: false,
Set provider: false in the object form instead when you still want indexing to apply to the MCP server’s index.
Excluding pages
Only indexable pages are searched. A page is left out of the index when it sets search.exclude in frontmatter:
search:
exclude: true
Hidden pages are also excluded by default. To index them anyway, opt in:
search: {
indexing: { includeHiddenPages: true },
}