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.
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().
The whole server side is nine lines: 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 score itself is dropped — the list 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 pathname is normalized 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 are worth planning around. buildSearchIndex() is called with no
version argument, so only current pages are candidates — nothing under
app/versions/ will ever be suggested. And on a large site the server hands
over every record, which ships to the client with the page; trim the index
inside app/not-found.tsx before passing it down if 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.