The records to search. Built by buildSearchIndex() on the server and passed
down through the doc layout.
Search
OwnDocs builds a full-text search index from your 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.
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. Every H2 through H6 is collected separately with an id
slugged by github-slugger.
export interface SearchItem {
slug: string
title: string
content: string
url: string
breadcrumb: string[]
headings: SearchHeading[]
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, soapp/features/platform/search.mdxcarries['features', 'platform'].- Pages under
app/versions/<version>/build into their own index, with the version as a URL prefix. scoreis filled in at query time by Fuse.js, not at build time.
Pass totalPages and the placeholder counts the site for you, reading
Search OwnDocs across 68 pages... instead of the plain Search OwnDocs....
The doc layout already wires it from getTotalPageCount().
Advanced
Matching runs through Fuse.js in the browser. Typing is debounced by 150ms, the
top 15 hits survive, and each result carries its Fuse score back onto the record
so ranking is preserved through the map to SearchItem.
new Fuse(searchIndex, {
keys: ['title', 'content'],
threshold: 0.4,
ignoreLocation: true,
includeScore: true,
})Only title and content are matched. slug, url, breadcrumb, and
headings ride along on the record and are available to anything else reading
the index — the 404 page searches slug and url too. If the Fuse instance
hasn't been created yet, the modal falls back to a plain case-insensitive
substring filter over title and content, capped at the same 15 results.
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 first 150 characters are shown instead. Below that sits
Page: <last URL segment> • Path: <url>, where a URL of / reads as Home.
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 between
-1(nothing selected) and the last result, scrolling it into view;Enteropens the highlighted page; hovering a row selects it too. TabandShift+Tabcycle inside a focus trap over the input, the close button, and the results.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 click outside the panel closes it, page scroll is locked while it's open, and closing resets the query, the results, and the selection.
Two empty states cover the rest: Start typing to search documentation before
the first keystroke, and No results found for "<query>" when nothing matches.
Each row can also show a Section: label naming the heading the match sits
under. getSectionInfo finds it by scanning backwards 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
searchIndexSearchItem[]requiredtotalPagesnumberOptional page count rendered into the placeholder as "Search OwnDocs across 68 pages...". Omit it and the placeholder reads "Search OwnDocs...".
keysstring[]Default: ['title', 'content']The record fields Fuse.js matches against.
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.
result limitnumberDefault: 15How many hits render, applied after Fuse has ranked them.
snippet lengthnumberDefault: 150Maximum characters in the preview line under each result title.