Skip to main content

Search

7 min readStableBeginner

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.

content/guides/rate-limits.mdx
MDX
# 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.

lib/search-index.ts
TypeScript
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:

  • slug is the filename without .mdx; index.mdx maps to its directory URL, and a root home.mdx maps to /.
  • breadcrumb comes from the directory path, so content/features/platform/search.mdx carries ['features', 'platform'].
  • Pages under content/versions/<version>/ carry that directory name as their version, with the version as a URL prefix. Everything in the main content/ tree is latest. buildAllVersionsSearchIndex() joins them into one index so a reader can search across versions.
  • score is 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.

components/GlobalSearch.tsx
TypeScript
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+K opens the modal from anywhere on the site; Escape closes 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; Home and End jump to the first and last loaded result; Enter opens the highlighted page and Cmd+Enter / Ctrl+Enter opens it in a new tab; hovering a row selects it too.
  • Tab and Shift+Tab cycle 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 through aria-activedescendant instead.
  • aria-expanded, aria-controls="search-results", aria-activedescendant, and aria-autocomplete="list" sit on the input; the results container is a role="listbox" and each row a role="option" with aria-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[]

The versions the site publishes. The filter row renders only when there is more than one.

currentVersionstringDefault: latest

The version the reader is on. Search starts scoped to it.

totalPagesnumber

Optional 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, content

The record fields Fuse.js matches against, weighted 3, 2, and 1.

useTokenSearchbooleanDefault: true

Scores each query word separately with BM25 inverse-document-frequency weighting instead of matching the whole query as one fuzzy pattern.

thresholdnumberDefault: 0.4

Fuzzy match tolerance. Lower is stricter; 0 demands an exact match.

ignoreLocationbooleanDefault: true

Matches anywhere in the text instead of favoring the start.

includeScorebooleanDefault: true

Returns each hit's relevance score, which is copied onto the result record as score.

debouncenumberDefault: 150

Milliseconds of idle typing before a query runs. Set on the setTimeout inside components/GlobalSearch.tsx, not a prop.

page sizenumberDefault: 20

How many hits render per batch. Scrolling near the bottom, or pressing Load more, adds another batch.

snippet lengthnumberDefault: 150

Maximum characters in the preview line under each result title.

Was this page helpful?

© 2026 DevOps. All rights reserved.