Skip to main content

Smart 404

3 min readStableIntermediate

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.

app/not-found.tsx
TSX
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.

  • keys decides 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.
  • threshold sets how forgiving the match is; 0.5 is loose enough to survive a typo, and a lower number is stricter.
  • ignoreLocation lets a match count anywhere in the text instead of only near the start.
  • includeScore gives Fuse the relevance number it sorts by. The score itself is dropped — the list maps straight to result.item and renders each page's title with its URL beside it.
components/NotFoundClient.tsx
TSX
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_reference searches as getting 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> calling history.back().
  • MAX_SUGGESTIONS is 5, applied after Fuse has sorted by score.
components/NotFoundClient.tsx
TSX
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[]required

The records to match against, built on the server and passed as the component's only prop. An empty array skips matching entirely.

keysstring[]Default: ['title', 'slug', 'url', 'content']

The search-record fields Fuse.js matches against.

thresholdnumberDefault: 0.5

Match tolerance. Lower is stricter; 0 demands an exact match.

ignoreLocationbooleanDefault: true

Counts a partial match anywhere in the searchable text.

includeScorebooleanDefault: true

Returns each result's relevance score so the list can be sorted by it.

MAX_SUGGESTIONSnumberDefault: 5

How many suggestions render. A module constant in components/NotFoundClient.tsx, not a prop.

revalidatenumberDefault: 60

Seconds before the 404 route rebuilds its copy of the search index.

Was this page helpful?