invisit
All posts

By Miraç Berk12 min read

How to track 404 errors and find the pages that link to them

Find the 404s visitors and crawlers hit and the link behind each: server logs, Search Console, GA4, Plausible, Matomo and more, with tested code.

A soft 3D browser window showing a torn page with a 404 sign, a dotted line leading back to the link that pointed at it, in violet dithered light and shadows.

To track 404 errors, make your 404 page report itself: send an analytics event with the missing path and the page the visitor came from, then group the events by path. Server logs and Google Search Console each show a different set of 404s, so read all three: logs for every request, crawlers included, Search Console for what Googlebot found, and analytics for the broken links people actually clicked.

On 3 November 2026 we ran the code in this guide in headless Chrome 155 against a local nginx 1.31.8 server and the development stack of invisit, the cookieless analytics tool we build: a plain HTML 404 page, a React Router single-page app, a log one-liner and a crawler check. Each tool’s own 404 recipe, from GA4 to PostHog, is in the second table.

Where do 404 errors show up: server logs, Search Console or analytics?

Each source sits at a different point between the link and the page:

Source What it records What it misses Where the visitor came from
Server or CDN logs Every request that reached the server, with its status: visitors, crawlers, scanners, and files like /favicon.ico Routes a single-page app resolves in the browser, and pages a CDN served from its cache The Referer header, cut to the origin when another site linked
Google Search Console URLs Googlebot crawled that answered 404 in the past month, and soft 404s Visitors, other crawlers, URLs Googlebot never tried “Referring page” in URL Inspection, which may be a page further up the chain
Browser analytics 404 pages a visitor’s browser loaded with your script running Crawlers, 404 pages served without your script, blocked scripts Whatever your 404 page sends

Search Console’s Page indexing report lists “only URLs that have shown 404 errors in the past month”, and its example list “is limited to 1,000 rows” (Page indexing report). Google’s advice is to fix the ones you cause: “In general, we recommend fixing only 404 errors that you link to yourself or list in a sitemap.”

Analytics adds the human count. In our test, one click on a broken link left two 404 lines in the nginx log, the missing page and the /favicon.ico Chrome requested on its own, and one event in analytics. Crawlers rarely reach analytics: for pages answering 404, Google says “rendering might be skipped” (JavaScript SEO basics), so your 404 page’s script may never run for Googlebot.

How to track 404 errors in GA4, Plausible, Matomo, Fathom, Umami and PostHog

Plausible, Matomo, Fathom and PostHog document a 404 setup. Google documents none for GA4, and Umami has no 404 page in its docs, so both use a custom event or what the page view already carries. As of November 2026:

Tool Documented way to record a 404 Where it shows up Where the visitor came from
GA4 None; filter the automatic page_view by your 404 page’s title An exploration with Page title, Page path, Page referrer page_referrer, the previous URL, your own pages included
Plausible plausible('404') on the 404 page, plus a custom event goal named 404 Goals tab, broken down by path Top Sources: where the visit started
Matomo setDocumentTitle('404/URL = ' + ... + ' /From = ' + document.referrer) Behaviour, Page Titles, under 404 Inside the title
Fathom fathom.trackEvent('404: ' + window.location.pathname) Events, one event per missing path Not kept: an event takes a name and an optional value
Umami None; umami.track('404', { path }) Events page, Properties tab Only what you add to the event data
PostHog posthog.capture('not_found') in the not-found page (tutorial) Events and insights, with $pathname $referrer, added to every event
invisit None built in; track('page_not_found', { path, referrer }) Events, with a breakdown by property Only what you send; own-site referrers are dropped at ingest

Sources: GA4 (dimensions, automatic events), Plausible’s 404 guide, Matomo’s 404 FAQ, Fathom’s 404 guide and events, Umami’s tracker functions and events, PostHog’s 404 tutorial and default properties, and invisit’s custom events.

GA4’s page_referrer keeps your own pages, so an exploration can name the page with the broken link without extra code. Explorations reach back only as far as the property’s data retention, 2 or 14 months on a standard property (data retention). If you send a page_not_found event instead, register its parameters as event-scoped custom dimensions first: a standard property has 50, and they report from 24 to 48 hours after you create them (custom dimensions). page_location keeps the query string; GA4’s data redaction strips email addresses by default on new properties, and query parameters only if you list them.

Plausible fills the path property of a 404 event from the page itself, so plausible('404') needs no arguments (v3.1.0 release notes). Its guide finds the source by filtering on a 404 URL and reading Top Sources. That is where the visit started, not the page holding the link. The guide also notes that 404 events count toward your billable monthly pageviews.

Matomo’s documented title puts location.search and the full document.referrer into a report, query strings included. A variant that keeps paths only, tested in Chrome with Matomo’s tracker:

404.html
<script>
  var _paq = (window._paq = window._paq || [])
  // Matomo's documented 404 title, without the query strings its own example sends.
  var from = document.referrer ? new URL(document.referrer) : null
  _paq.push([
    'setDocumentTitle',
    '404/URL = ' +
      encodeURIComponent(location.pathname) +
      ' /From = ' +
      encodeURIComponent(from ? from.origin + from.pathname : ''),
  ])
  // The page URL is sent too: keep its query string and fragment out as well.
  _paq.push(['setCustomUrl', location.origin + location.pathname])
  _paq.push(['trackPageView'])
  // ...the rest of your Matomo tracking code
</script>

Without setCustomUrl, our test hit still carried old-post?email=jane@example.com&utm_source=news#comments as its URL, even with a clean title. setCustomUrl replaces it.

How to report a 404 from a plain HTML 404 page

Put the tracker and a few lines of script on the page your server sends with the 404 status. The script reads the missing path from location.pathname and the previous page from document.referrer, and drops both query strings, which can carry emails, tokens and password reset codes:

404.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Page not found</title>
    <script
      async
      src="https://api.invisit.dev/invisit.js"
      data-site="<site id>"
      data-collector="https://api.invisit.dev"
    ></script>
    <script>
      // Reports the missing path and where the visitor came from. Only paths and origins are
      // sent: query strings and fragments can carry emails, tokens and reset codes.
      addEventListener('load', () => {
        let referrer = 'none'
        if (document.referrer) {
          const from = new URL(document.referrer)
          referrer = from.origin === location.origin ? from.pathname : from.origin + from.pathname
        }
        window.invisit?.track('page_not_found', { path: location.pathname, referrer })
      })
    </script>
  </head>
  <body>
    <h1>Page not found</h1>
    <p><a href="/">Go to the home page</a> or <a href="/search">search the site</a>.</p>
  </body>
</html>

An async script runs before the window’s load event, so the tracker is ready when the listener fires, and ?. skips the call when a blocker stopped the script. For another tool, swap the track line for its call from the table.

Serve this page at the missing URL with a 404 status. In nginx, error_page 404 /404.html; does that. Redirecting to a /404 address instead turns every missing path into /404 in your reports, and the original URL answers with a redirect rather than a 404.

What the snippet sent in our test, with each event checked through invisit’s API:

How the visitor arrived path referrer
Clicked a broken link on /blog/hello /blog/helo-world /blog/hello
Clicked a link on another site to /old-post?email=...&utm_source=news#comments /old-post http://news.example.org:8796/
Typed the address /typo-page none

The nginx log kept email=jane%40example.com in its request line for the second visit. invisit’s page view for it kept utm_source=news and dropped the email parameter and the fragment, which its tracker strips on its own. If your paths themselves can hold an address, as in /unsubscribe/jane@example.com, mask it before sending: invisit replaces email addresses in property values with [redacted], and other tools may not.

What is a soft 404, and why do single-page apps cause them?

A soft 404 is a page that tells the visitor it does not exist while the server answers 200. Google detects them from the content, reports them as “Soft 404” in Search Console and recommends “returning a 404 response code for truly ‘not found’ pages” (Page indexing report).

Single-page apps produce them by design. The server answers every path with the app’s HTML and a 200, and the router decides in the browser that a route does not exist. In our test, nginx with try_files $uri /index.html answered 200 to a Googlebot user agent asking for /docs/v1, a route the app does not have. Google’s JavaScript SEO guide gives two fixes when the server cannot send the status: redirect with JavaScript to a URL your server answers with a 404, or add <meta name="robots" content="noindex"> with JavaScript. If your server can tell which paths exist, from a route list or a prerendered build, answer the rest with a real 404 and skip both.

Server logs cannot flag these. When our test app’s visitor clicked an in-app link to a missing route, nginx logged nothing, because the router never asked the server, and the deep link from another site was logged as a 200.

Title-based reports, the usual GA4 method and Matomo’s convention, break here too: they need the not-found title in place when the page view is sent, which in an app is often before the router renders. In our test, invisit’s page view for the deep link to /docs/v1 carried App, the HTML shell’s title. Send an event from the not-found route instead; our SPA page view guide covers how each tool times titles.

How to track 404s in a single-page app’s not-found route

Send the event from the catch-all route’s component, and take the referrer from the router, not from document.referrer. document.referrer is set once, when the document loads: in our test it still read http://news.example.org:8796/ after two in-app navigations. In React Router 8, remember the previous path with router.subscribe(), which runs before React renders the new route, so the not-found effect reads the path before the current one:

src/previous-path.ts
import type { DataRouter } from 'react-router'

let current: string | null = null
let previous: string | null = null

// document.referrer keeps the referrer of the first page load for the life of the app, so the
// page a client-side navigation came from has to be remembered here.
export function rememberPaths(router: DataRouter) {
  current = router.state.location.pathname
  router.subscribe(({ location }) => {
    if (location.pathname === current) return
    previous = current
    current = location.pathname
  })
}

export const pathBefore = () => previous
src/not-found.tsx
import { useEffect } from 'react'
import { useLocation } from 'react-router'
import { track } from '@invisitdev/web'
import { pathBefore } from './previous-path'

export function NotFound() {
  const { pathname } = useLocation()

  useEffect(() => {
    let referrer = pathBefore()
    if (!referrer && document.referrer) {
      const from = new URL(document.referrer)
      referrer =
        from.origin === window.location.origin ? from.pathname : from.origin + from.pathname
    }
    track('page_not_found', { path: pathname, referrer: referrer ?? 'none' })
  }, [pathname])

  // The server answered 200, as it does for every route of the app: noindex keeps this page
  // out of Google's index. React 19 moves <title> and <meta> into <head>.
  return (
    <main>
      <title>Page not found</title>
      <meta name="robots" content="noindex" />
      <h1>Page not found</h1>
    </main>
  )
}
src/router.tsx
import { createBrowserRouter } from 'react-router'
import { Home, Pricing } from './pages'
import { NotFound } from './not-found'

export const router = createBrowserRouter([
  { path: '/', element: <Home /> },
  { path: '/pricing', element: <Pricing /> },
  { path: '*', element: <NotFound /> },
])
src/main.tsx
import { createRoot } from 'react-dom/client'
import { RouterProvider } from 'react-router/dom'
import { init } from '@invisitdev/web'
import { rememberPaths } from './previous-path'
import { router } from './router'

init({ site: '<site id>' })
rememberPaths(router)

createRoot(document.getElementById('root')!).render(<RouterProvider router={router} />)

We built this with React 19.3.1, React Router 8.5.1 and @invisitdev/web 0.2.1, and it type-checks under TypeScript’s strict. An in-app link from /pricing to /plans/2024 sent { path: '/plans/2024', referrer: '/pricing' }. A link from another site to /docs/v1 sent the other site’s origin, and in both cases the noindex tag landed in <head>. In development, React’s Strict Mode runs effects twice, so expect two events there; a production build sends one.

Which page linked to my 404?

The referrer of the 404 view tells you, within limits set by the linking page’s referrer policy. Browsers default to strict-origin-when-cross-origin (MDN), which means:

For links from other sites, Search Console’s Links report lists, for one of your pages, the sites linking to it and the pages on each site. It is a sample, and it can omit non-indexed URLs, which is what a 404 is. In URL Inspection, the “Referring page” of a 404 may be “a grandparent or great-grandparent of a page that links to this URL”, and an empty value “doesn’t mean that no referring page exists” (URL Inspection).

Keep the referrer of the 404 view apart from the source of the visit. Plausible’s Top Sources and invisit’s Sources card report where a visit started. A visitor who arrived from Google, read three pages and then clicked your broken link counts as Google there, and only the referrer of the 404 view names the page with the bad link.

How to find 404 errors in your server logs

Count the 404 lines in your access log by path, referrer and kind of client. For nginx’s default combined format, where the request, referrer and user agent sit between double quotes:

Terminal
awk -F'"' '$3 ~ /^ 404 / {
  split($2, request, " "); path = request[2]; sub(/\?.*/, "", path)
  from = $4; sub(/\?.*/, "", from)
  agent = ($6 ~ /[Bb]ot|[Cc]rawl|[Ss]pider/) ? "crawler" : "browser"
  print path, from, agent
}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -30

It drops query strings, so emails and tokens stay out of the output. On our test log, with mawk 1.3.4 and with BusyBox awk in the nginx:alpine image, it printed:

Output
      1 /wp-login.php - crawler
      1 /typo-page - browser
      1 /old-post http://news.example.org:8796/ browser
      1 /old-post - crawler
      1 /favicon.ico http://qa.example.com:8796/blog/hello browser
      1 /blog/helo-world http://qa.example.com:8796/blog/hello browser
      1 /blog/helo-world - crawler

The crawler label matches only user agents that name themselves; our bot traffic guide covers the rest. Crawler 404s on URLs you never linked, like /wp-login.php, are scanners and need nothing. Repeated crawler 404s on pages you did link are worth a redirect, since Googlebot “will probably continue to try this URL for some period of time” (Page indexing report).

How invisit records 404s and crawler responses

In invisit, send a custom event from the 404 page. The page view already carries the missing path, but invisit stores no response status for page views, and the page title is not a dimension you can filter or group a report by. Each page_not_found event is billed as a custom event.

The event shows under Events, with each property broken down by its most common values. Click a path value and the referrer breakdown narrows to the pages that led there. Each visit’s steps, live in the dashboard and at any age through the REST API, show the page a visitor saw before the 404. For the linking page across all visits you need the event’s own referrer, because invisit drops a referrer from your own domain when it stores a page view: in our test, the 404 view reached from /blog/hello was stored with no referrer, while the one reached from another site kept news.example.org as its source.

Crawler 404s come from the server, not the page. @invisitdev/server records crawler reads, which are never billed, but handle() runs before your app answers, so it records every read as a 200. To keep the real status, call proxy() before your app and crawlers() after it, as the Hono guide does:

src/index.ts
import { Hono } from 'hono'
import { invisit } from './lib/invisit'

const app = new Hono()

app.use(async (c, next) => {
  // Behind Vercel, Netlify or Cloudflare. On a bare Bun or Node server, pass { ip } as the guide shows.
  const proxied = await invisit.proxy(c.req.raw)
  if (proxied) return proxied
  await next()
  void invisit.crawlers(c.req.raw, { status: c.res.status })
})

We sent one GPTBot read through handle() and one through crawlers() with status 404 to our development collector, using @invisitdev/server 0.3.0; the crawler report counted one 200 and one 404 for AI training crawlers. Its Responses card counts reads by status for each crawler group, and clicking a 4xx or 5xx row lists the paths that answered with it, so you can see which missing pages crawlers keep asking for; the REST API returns the same list as error_paths. Use Search Console for Googlebot’s own view; invisit has no Search Console integration.

How to fix 404 errors with redirects

Redirect a URL only when a page with the same content exists; otherwise let it answer 404. Google says it treats 410 “the same as 404s”, and calls redirecting missing pages to the homepage a soft 404 that “can be problematic” (404 errors):

What the 404 is What to do
A page that moved or was renamed A permanent redirect (301 or 308) to the new address
A broken link on your own site Fix the link, and redirect too if the wrong URL was shared or indexed
A typo in another site’s link Redirect it if it brings visitors, and ask the site to fix the link
A page you deleted with no replacement Leave the 404, or send 410
Scanner paths such as /wp-login.php Nothing

Plausible’s 404 guide suggests a category page or the homepage when nothing fits. We follow Google here: a 404 page with search and your main links serves that visitor better than a homepage they did not ask for.

The redirect itself, in nginx (tested with nginx 1.31.8), a Netlify _redirects file (301 by default, Netlify docs) and vercel.json (permanent: true sends a 308, up to 2,048 rules, Vercel docs):

/etc/nginx/conf.d/site.conf
location = /old-pricing { return 301 /pricing; }
location /docs/v1/ { rewrite ^/docs/v1/(.*)$ /docs/$1 permanent; }
public/_redirects
/old-pricing   /pricing   301
/docs/v1/*     /docs/:splat   301
vercel.json
{
  "redirects": [
    { "source": "/old-pricing", "destination": "/pricing", "permanent": true },
    { "source": "/docs/v1/:path*", "destination": "/docs/:path*", "permanent": true }
  ]
}

After deploying, request the old URL with curl -I and check for the 301 or 308, then watch that path’s 404 events stop. If they continue, the link points at a variant your rule does not match, such as a trailing slash or different capitalisation.

Checklist: track 404s in an afternoon

The first step is the cheapest: request a URL that cannot exist with curl -I, and if the answer is a 200, fix that before tracking anything.