The records to match against, built on the server and passed as the component's only prop. An empty array skips matching entirely.
Smart 404
A dead link doesn't have to end the visit. OwnDocs takes the failed pathname, runs it through Fuse.js against the same search index that powers Cmd+K, and offers up to five pages the reader probably wanted. Matching runs in the browser, so the failed path never travels past the standard 404 response. What follows covers the server half, the matching options, and the limits of the suggestion list.
Quick Start
There is nothing to author on a page and nothing to switch on: every missing path already renders the suggestion list.
Try a missing path
Open a route that was never created and watch the 'Did you mean' list fill in.
Two ways back
Every 404 ends with a 'Back to home' link and a 'Go back' button calling history.back().
Nine lines cover the server side: build the index, hand it to the client component.
import NotFoundClient from '@/components/NotFoundClient'
import { buildSearchIndex } from '@/lib/search-index'
export const revalidate = 60
export default function NotFound() {
const searchIndex = buildSearchIndex()
return <NotFoundClient searchIndex={searchIndex} />
}The client renders a centered card outside the documentation shell: no sidebar,
no table of contents, no search modal. Above the suggestions sit a file-question
icon, a 404 Error eyebrow, the heading Page not found, and one line of
explanation.
Adding Options
Matching lives in one object, so tuning it is a single edit in
components/NotFoundClient.tsx.
keysdecides which parts of a search record can match: title, slug, URL, and the page's stripped prose. Slug and URL are what let a mistyped path still find its page.thresholdsets how forgiving the match is;0.5is loose enough to survive a typo, and a lower number is stricter.ignoreLocationlets a match count anywhere in the text instead of only near the start.includeScoregives Fuse the relevance number it sorts by. The list drops the score itself: it maps straight toresult.itemand renders each page's title with its URL beside it.
const fuse = new Fuse(searchIndex, {
keys: ['title', 'slug', 'url', 'content'],
threshold: 0.5,
includeScore: true,
ignoreLocation: true,
})
return fuse
.search(query)
.slice(0, MAX_SUGGESTIONS)
.map((result) => result.item)Advanced
Three details do the recovery work around the search call itself.
- The component normalizes the pathname before matching: leading slashes go,
then
-,_, and/all become spaces, so/getting-started/api_referencesearches asgetting started api reference. - An empty query, an empty index, or a thrown error returns
[], and the "Did you mean" block hides itself; the reader still gets "Back to home" and a real<button>callinghistory.back(). MAX_SUGGESTIONSis5, applied after Fuse has sorted by score.
const suggestions = useMemo(() => {
if (!pathname || searchIndex.length === 0) return []
const query = pathname
.replace(/^\/+/, '')
.replace(/[-_/]+/g, ' ')
.trim()
if (!query) return []
try {
// Fuse.js search, sliced to MAX_SUGGESTIONS
} catch {
return []
}
}, [pathname, searchIndex])The work is memoized on pathname and searchIndex, so a client-side
navigation to another dead link re-ranks without rebuilding anything on the
server.
Two limits shape what the list can offer. app/not-found.tsx calls
buildSearchIndex() with no version argument, so only current pages are
candidates and nothing under content/versions/ is ever suggested. On a large
site the server also hands over every record, which ships to the client with the
page. Trim the index inside app/not-found.tsx before passing it down when that
payload matters.
Options
searchIndexSearchItem[]requiredkeysstring[]Default: ['title', 'slug', 'url', 'content']The search-record fields Fuse.js matches against.
thresholdnumberDefault: 0.5Match tolerance. Lower is stricter; 0 demands an exact match.
ignoreLocationbooleanDefault: trueCounts a partial match anywhere in the searchable text.
includeScorebooleanDefault: trueReturns each result's relevance score so the list can be sorted by it.
MAX_SUGGESTIONSnumberDefault: 5How many suggestions render. A module constant in
components/NotFoundClient.tsx, not a prop.
revalidatenumberDefault: 60Seconds before the 404 route rebuilds its copy of the search index.