The versions the site publishes. The filter row renders only when there is more than one.
Search
OwnDocs builds a full-text search index from the MDX pages at build time. Readers open the modal with Cmd+K (Ctrl+K on Windows and Linux) and land on a matching page. No external service, no API key, nothing to configure. What follows covers the record shape, the matching, version filtering, and the keyboard surface.
Quick Start
Write a page with an H1 and it joins the index on the next build. That first heading becomes the result title, and the file path becomes the URL.
Open search
Press Cmd+K on macOS or Ctrl+K on Windows and Linux, from any page.
Or click the box
The trigger button sits above the article and in the mobile header, with a matching keyboard hint.
# Rate limits
Every API key is capped at 60 requests per minute.That page indexes as the title Rate limits at /guides/rate-limits, with the
sentence below it as searchable content.
Adding Options
Each page becomes one record. The indexer strips code fences, Mermaid blocks,
capitalized component tags, blockquotes, and link syntax, so only prose reaches
the content field. It also collects every H2 through H6 separately, with an id
slugged by github-slugger.
export interface SearchItem {
slug: string
title: string
content: string
url: string
breadcrumb: string[]
headings: SearchHeading[]
version: string
versionLabel: string
isLatest: boolean
score?: number
}A few naming rules fall out of that shape:
slugis the filename without.mdx;index.mdxmaps to its directory URL, and a roothome.mdxmaps to/.breadcrumbcomes from the directory path, socontent/features/platform/search.mdxcarries['features', 'platform'].- Pages under
content/versions/<version>/carry that directory name as theirversion, with the version as a URL prefix. Everything in the maincontent/tree islatest.buildAllVersionsSearchIndex()joins them into one index so a reader can search across versions. scoreis filled in at query time by Fuse.js, not at build time.
Pass totalPages and the placeholder counts the site, reading
Search OwnDocs across 68 pages... instead of the plain Search OwnDocs....
The doc layout already wires it from getTotalPageCount().
Advanced
No page ships the index in its payload. The modal fetches /search-index.json
the first time it opens, so the browser downloads the records once per session
instead of carrying them from page to page.
Matching runs through Fuse.js in the browser. components/GlobalSearch.tsx
debounces typing by 150ms, renders results 20 at a time, and copies each Fuse
score back onto the record, so the map to SearchItem keeps the ranking.
new Fuse(index, {
keys: [
{ name: 'title', weight: 3 },
{ name: 'headings.text', weight: 2 },
{ name: 'content', weight: 1 },
],
useTokenSearch: true,
tokenMatch: 'any',
threshold: 0.4,
ignoreLocation: true,
includeScore: true,
})useTokenSearch scores each query word on its own against an inverted index,
using BM25 inverse-document-frequency weighting. Without it, Fuse scores the
whole query as one fuzzy pattern and gives up past 32 characters, which is why
multi-word queries used to drift to the wrong page. Titles weigh three times
body text, headings twice.
slug, url, breadcrumb, and version ride along on the record and are
available to anything else reading the index: the 404 page searches slug and
url too.
Version Filtering
When the site has more than one version, a filter row sits above the results with one chip per version plus an All versions chip. Each chip shows how many hits that version holds for the current query, counted before the filter is applied, so a chip never promises results it cannot deliver.
- Search starts scoped to the version the reader is on.
- Any result that is not from the latest version carries a version badge, and under All versions every result is badged.
- Under All versions, the same page across versions collapses into one row, keeping the best-ranked copy and naming the others.
- When a query finds nothing in the current version but matches elsewhere, the empty state offers to widen the search.
toVersionNeutralPath() in lib/search-scope.ts strips the version prefix so
the same page matches across versions. It lives apart from lib/search-index.ts
because the index builder reads the filesystem and cannot be imported into a
client component.
The Model Context Protocol (MCP) search_docs tool takes the same idea as a
version argument, defaulting to the latest documentation and accepting all.
The AI chat sends the version the reader is on, so it answers from that
version's pages instead of always answering from the latest.
Each row shows the title, then a snippet of at most 150 characters built around
the first literal occurrence of the query: 50 characters of lead-in, the query,
100 characters after, with ... on whichever side was cut. No match in the body
means the row shows the first 150 characters instead. The snippet wraps matched
query words in <mark>. Below that sits
Page: <last URL segment> • Path: <url>, where a URL of / reads as Home.
Screen readers skip that line so the spoken result stays short.
Results load 20 at a time. Scrolling near the bottom of the list pulls in the next batch, and a Load more button below the list does the same thing for anyone who cannot trigger a scroll, which is what keeps the feature usable with a screen reader.
The input is a proper ARIA combobox, and the keyboard covers the whole interaction:
Cmd+K/Ctrl+Kopens the modal from anywhere on the site;Escapecloses it, and the handler is global, so it works whether or not the input has focus.- Arrow keys move the highlighted option and wrap at both ends, scrolling it
into view;
HomeandEndjump to the first and last loaded result;Enteropens the highlighted page andCmd+Enter/Ctrl+Enteropens it in a new tab; hovering a row selects it too. TabandShift+Tabcycle inside a focus trap over the input, the version chips, the close button, and the Load more button. Result rows are not focusable, which is what the combobox pattern requires; keyboard traversal runs througharia-activedescendantinstead.aria-expanded,aria-controls="search-results",aria-activedescendant, andaria-autocomplete="list"sit on the input; the results container is arole="listbox"and each row arole="option"witharia-selected.- A polite
role="status"region announces the result count, and a separate assertive region announces only the no-results case. The input is described by a standing "Results update as you type" hint rather than announcing on every keystroke. - A click outside the panel closes it, page scroll is locked while it's open, and closing resets the query, the results, and the selection.
The empty states cover the rest: the modal offers the last five queries back as
recent searches, Start typing to search documentation shows before the first
keystroke when there are none, and No results found for "<query>" in <scope>
shows when nothing matches. While the index is still downloading the modal says
so, and a failed download offers a retry.
Each row can also show a Section: label naming the heading the match sits
under. getSectionInfo finds it by scanning backward for a Markdown heading
line, so it only fires for records whose content still holds raw ## markers.
The built-in indexer strips those markers and collapses the page to a single
line, so with the default index this label stays empty and never renders.
Options
versionsDocsVersion[]currentVersionstringDefault: latestThe version the reader is on. Search starts scoped to it.
totalPagesnumberOptional page count rendered into the placeholder as "Search OwnDocs across 68 pages...". Omit it and the placeholder reads "Search OwnDocs...".
keysFuseOptionKey[]Default: title, headings.text, contentThe record fields Fuse.js matches against, weighted 3, 2, and 1.
useTokenSearchbooleanDefault: trueScores each query word separately with BM25 inverse-document-frequency weighting instead of matching the whole query as one fuzzy pattern.
thresholdnumberDefault: 0.4Fuzzy match tolerance. Lower is stricter; 0 demands an exact match.
ignoreLocationbooleanDefault: trueMatches anywhere in the text instead of favoring the start.
includeScorebooleanDefault: trueReturns each hit's relevance score, which is copied onto the result record as
score.
debouncenumberDefault: 150Milliseconds of idle typing before a query runs. Set on the setTimeout
inside components/GlobalSearch.tsx, not a prop.
page sizenumberDefault: 20How many hits render per batch. Scrolling near the bottom, or pressing Load more, adds another batch.
snippet lengthnumberDefault: 150Maximum characters in the preview line under each result title.