Skip to main content

Search

4 min readStableBeginner

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.

app/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. Every H2 through H6 is collected 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[]
  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 app/features/platform/search.mdx carries ['features', 'platform'].
  • Pages under app/versions/<version>/ build into their own index, with the version as a URL prefix.
  • score is 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.

components/GlobalSearch.tsx
TypeScript
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+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 between -1 (nothing selected) and the last result, scrolling it into view; Enter opens the highlighted page; hovering a row selects it too.
  • Tab and Shift+Tab cycle inside a focus trap over the input, the close button, and the results.
  • 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 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[]required

The records to search. Built by buildSearchIndex() on the server and passed down through the doc layout.

totalPagesnumber

Optional 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.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.

result limitnumberDefault: 15

How many hits render, applied after Fuse has ranked them.

snippet lengthnumberDefault: 150

Maximum characters in the preview line under each result title.

Was this page helpful?