Making the 404 page more helpful Posted on September 02, 2025 by Dave Fowler   #AI #code

I've been vibe coding some upgrades to this blog. And I recently just did another upgrade - implementing a fun idea I've had for a while: make the "404 Page Not Found" error page actually helpful by suggesting where you might have meant to go.

This helps when I reorganize or rename things and forget to wire up a redirect. Instead of a dead end, you get a few likely links.

404 helper

If you want to measure which URLs are breaking (and where visitors came from) so you can prioritize fixes, see: Tracking 404s with GA4.

How it works

  • It takes the current URL's slug and compares it to all known site URLs.
  • It uses Levenshtein distance to find the closest matches, with a simple directory-overlap tie-breaker.
  • It renders up to three suggestions right on the 404 page.
  • It reads sitemap.xml client-side (namespace-aware) to get canonical URLs.
  • It fetches titles for the suggested pages and displays “Title (path)”.
  • If there are no good matches, the suggestions section stays hidden.

The snippet

This is the core of the client-side code currently running on my 404 page. If you have a standard sitemap.xml already, it's used to provide the possible URLs and titles to match against.

If you'd like to add it to your page, just add the following to your own 404 page.

<div id="suggestions" class="til-404-suggestions" style="display: none;"></div>
<script>
  (function () {
    const PATH_TO_SITEMAP = '/sitemap.xml';

    const levenshtein = (a, b) => {
      const m = a.length;
      const n = b.length;
      const dp = Array.from({ length: m + 1 }, () => new Array(n + 1));
      for (let i = 0; i <= m; i++) dp[i][0] = i;
      for (let j = 0; j <= n; j++) dp[0][j] = j;
      for (let i = 1; i <= m; i++) {
        for (let j = 1; j <= n; j++) {
          const cost = a[i - 1] === b[j - 1] ? 0 : 1;
          dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + cost);
        }
      }
      return dp[m][n];
    };

    const getSlugFromPath = (path) => {
      const parts = path.split('/').filter(Boolean);
      if (parts.length === 0) return '';
      const last = parts[parts.length - 1];
      return last.replace(/\.html$/i, '');
    };

    const currentPath = window.location.pathname;
    const currentSlug = getSlugFromPath(currentPath);
    if (!currentSlug) return;

    const getTitleForUrl = (url) => {
      try {
        const key = `til404:title:${url}`;
        const cached = sessionStorage.getItem(key);
        if (cached) return Promise.resolve(cached);
      } catch (_) {}
      return fetch(url)
        .then((r) => (r.ok ? r.text() : ''))
        .then((html) => {
          const doc = new DOMParser().parseFromString(html, 'text/html');
          const title = (doc.querySelector('title') || {}).textContent || '';
          const h1 = (doc.querySelector('h1') || {}).textContent || '';
          const result = (title || h1 || url).trim();
          try {
            sessionStorage.setItem(`til404:title:${url}`, result);
          } catch (_) {}
          return result;
        })
        .catch(() => url);
    };

    const scoreAndRender = (urls) => {
      if (!Array.isArray(urls) || urls.length === 0) return;
      const currentDirParts = currentPath.split('/').filter(Boolean).slice(0, -1);
      const best = urls
        .map((u) => {
          const dirParts = (u.dir || '').split('/').filter(Boolean);
          let overlap = 0;
          for (let i = 0; i < Math.min(dirParts.length, currentDirParts.length); i++) {
            if (dirParts[i] === currentDirParts[i]) overlap++;
            else break;
          }
          return {
            url: u.path,
            slug: u.slug,
            slugScore: levenshtein(currentSlug, u.slug || ''),
            dirScore: -overlap,
          };
        })
        .sort((a, b) => a.slugScore - b.slugScore || a.dirScore - b.dirScore)
        .slice(0, 3);

      const root = document.getElementById('suggestions');
      if (!root) return;
      const any = best.filter((s) => s.slugScore < Math.max(currentSlug.length, 6));
      if (any.length === 0) return; // no UI if nothing helpful
      root.style.display = '';

      const heading = document.createElement('p');
      heading.textContent = 'Based on your URL you may have been meaning to go to one of these:';
      root.appendChild(heading);

      const list = document.createElement('ul');
      root.appendChild(list);

      Promise.all(any.map((s) => getTitleForUrl(s.url))).then((titles) => {
        any.forEach((s, idx) => {
          const li = document.createElement('li');
          const a = document.createElement('a');
          a.href = s.url;
          a.textContent = titles[idx] || s.url;
          const small = document.createElement('small');
          small.style.marginLeft = '6px';
          small.textContent = `(${s.url})`;
          li.appendChild(a);
          li.appendChild(small);
          list.appendChild(li);
        });
      });
    };

    const parseSitemap = (xmlText) => {
      try {
        const parser = new DOMParser();
        const doc = parser.parseFromString(xmlText, 'application/xml');
        const byNS = doc.getElementsByTagNameNS
          ? Array.from(doc.getElementsByTagNameNS('*', 'loc'))
          : [];
        const byTag = Array.from(doc.getElementsByTagName('loc') || []);
        const locs = byNS && byNS.length > 0 ? byNS : byTag;
        const urls = locs
          .map((el) => {
            const href = (el.textContent || '').trim();
            try {
              const u = new URL(href);
              let path = u.pathname || '/';
              if (path === '/' || path.toLowerCase() === '/404') return null;
              path = path.endsWith('/') ? path : `${path}/`;
              const parts = path.split('/').filter(Boolean);
              const lastPart = parts.length > 0 ? parts[parts.length - 1] : '';
              const slug = lastPart.replace(/\.html$/i, '');
              const dir = parts.slice(0, -1).join('/');
              return { path, dir, slug };
            } catch (_) {
              return null;
            }
          })
          .filter(Boolean);
        return urls;
      } catch (_) {
        return [];
      }
    };

    fetch(PATH_TO_SITEMAP)
      .then((r) => (r.ok ? r.text() : ''))
      .then((xml) => {
        const urls = parseSitemap(xml);
        if (urls && urls.length > 0) scoreAndRender(urls);
      })
      .catch(() => {});
  })();
</script>

Why this approach

  • Lightweight: runs entirely client-side with a tiny JSON index.
  • Resilient: works even if I forget a redirect; no server logic needed.
  • Good-enough relevance: Levenshtein + directory overlap picks sensible candidates.

Future idea: add click tracking on the suggestions to surface missing redirects in reports.