Search
The structured search index, record types, keyboard shortcuts, and the search config.
Search runs entirely in the browser. The dialog fetches a JSON index once, scores records as you type, and groups hits by page. There is no external service to configure.
Setup#
Serve the index from a route handler at the URL in search.indexUrl (default /api/search):
import { source } from "@/lib/source";
export const dynamic = "force-static";
export async function GET() {
return Response.json(await source.getSearchIndex());
}RootProvider receives the search section of the config and renders the dialog plus the keyboard shortcut. The header shows a "Search..." trigger with the shortcut hint.
The index#
getSearchIndex() returns a flat SearchRecord[]. Every page contributes one page record, one heading record per heading, and one text record per paragraph, list item, table cell, or blockquote.
interface SearchRecordBase {
id: string;
pageUrl: string;
url: string;
breadcrumbs: string[];
tag?: string;
}
interface SearchPageRecord extends SearchRecordBase {
type: "page";
title: string;
description?: string;
}
interface SearchHeadingRecord extends SearchRecordBase {
type: "heading";
content: string;
pageTitle: string;
}
interface SearchTextRecord extends SearchRecordBase {
type: "text";
content: string;
headingId?: string;
pageTitle: string;
}
type SearchRecord = SearchPageRecord | SearchHeadingRecord | SearchTextRecord;urlon heading and text records includes the#hash, so selecting a hit scrolls to the right section.breadcrumbsholds the folder names leading to the page and is shown under each group.tagcomes from the page's frontmatter and is there for custom filtering if you build your own UI on top of the index.
The index is compiled from every page and cached until any content file changes, so the first request after a content edit is the only slow one.
Scoring and grouping#
Matches are scored per record: a hit in a page title counts far more than one in a description, a heading more than body text, and exact phrase matches more than scattered words. Records are then grouped by page, groups are ordered by their best hit, and each group shows its top hits under the page title. Only the best few groups are shown so the list stays scannable.
Keyboard#
| Key | Action |
|---|---|
⌘K / Ctrl+K | Open the dialog. The letter is search.hotKey. |
↑ / ↓ | Move the selection across groups and hits. |
Enter | Navigate to the selected result. |
Esc | Close the dialog. |
D | Toggle light and dark (not a search key, but registered by the same provider; disable with theme.hotKey: false). |
Hotkeys are ignored while an input, textarea, or editable element has focus.
Config#
| Name | Type | Default | Description |
|---|---|---|---|
search.enabled | boolean | true | Shows the trigger in the header, registers the shortcut, and renders the dialog. |
search.indexUrl | string | "/api/search" | Where the dialog fetches the SearchRecord[] JSON. |
search.hotKey | string | "k" | Key combined with Meta (macOS) or Ctrl that opens the dialog. |
Static index#
Because the route handler is force-static, the index becomes a JSON file in the build output. If you prefer to generate it yourself, write getSearchIndex() to public/search.json in a build script and set search: { indexUrl: "/search.json" }.
Custom UI#
useSearch() from pzzadocs/ui returns { enabled, indexUrl, open, setOpen }, so a custom button anywhere inside RootProvider can open the dialog:
"use client";
import { useSearch } from "pzzadocs/ui";
export function SearchButton() {
const { setOpen } = useSearch();
return <button onClick={() => setOpen(true)}>Search</button>;
}