[invisit](https://invisit.dev) · [Docs](https://invisit.dev/docs) · [llms.txt](https://invisit.dev/llms.txt) · [llms-full.txt](https://invisit.dev/llms-full.txt)

# 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.

- Author: Miraç Berk
- Published: 2026-11-03
- Page: https://invisit.dev/blog/track-404-pages
- Tags: broken-links, events, google-analytics, single-page-apps

![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.](https://invisit.dev/_astro/cover.B6KBqNHH.png)

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](https://support.google.com/webmasters/answer/7440203)). 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](https://developers.google.com/search/docs/crawling-indexing/javascript/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](https://support.google.com/analytics/answer/9143382), [automatic events](https://support.google.com/analytics/answer/9234069)), [Plausible's 404 guide](https://plausible.io/docs/error-pages-tracking-404), [Matomo's 404 FAQ](https://matomo.org/faq/how-to/faq_60/), [Fathom's 404 guide](https://usefathom.com/docs/events/404-page-tracking) and [events](https://usefathom.com/docs/events/overview), Umami's [tracker functions](https://docs.umami.is/docs/tracker-functions) and [events](https://docs.umami.is/docs/track-events), PostHog's [404 tutorial](https://posthog.com/tutorials/broken-link-checker) and [default properties](https://posthog.com/docs/data/events), and invisit's [custom events](https://invisit.dev/docs/guides/events.md#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](https://support.google.com/analytics/answer/7667196)). 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](https://support.google.com/analytics/answer/10075209)). `page_location` keeps the query string; GA4's [data redaction](https://support.google.com/analytics/answer/13544947) 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](https://github.com/plausible/analytics/releases/tag/v3.1.0)). 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:

```html title="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`](https://developer.matomo.org/api-reference/tracking-javascript) 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:

```html title="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](https://support.google.com/webmasters/answer/7440203)).

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](https://developers.google.com/search/docs/crawling-indexing/javascript/javascript-seo-basics) 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](/blog/spa-page-views) 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:

```ts title="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
```

```tsx title="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>
  )
}
```

```tsx title="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 /> },
])
```

```tsx title="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](https://react.dev/reference/react/StrictMode) 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](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Referrer-Policy)), which means:

- A link on your own site sends the full URL of the page it sits on, query string included. You can open that page and fix the link.
- A link on another site sends only that site's origin, such as `https://news.example.org/`. You know the site, not the page.
- An HTTPS page linking to an HTTP URL sends nothing, and so do links with `rel="noreferrer"` and pages with `Referrer-Policy: no-referrer`. Typed and bookmarked addresses send none.

For links from other sites, Search Console's [Links report](https://support.google.com/webmasters/answer/9049606) 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](https://support.google.com/webmasters/answer/9012289)).

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:

```sh title="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:

```text title="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](/blog/bot-traffic) 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](https://support.google.com/webmasters/answer/7440203)).

## 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](https://invisit.dev/docs/api/rest.md), 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](https://invisit.dev/docs/server/frameworks.md#hono) does:

```ts title="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](https://support.google.com/webmasters/answer/2445990)):

| 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](https://docs.netlify.com/manage/routing/redirects/overview/)) and `vercel.json` (`permanent: true` sends a 308, up to 2,048 rules, [Vercel docs](https://vercel.com/docs/routing/redirects/configuration-redirects)):

```nginx title="/etc/nginx/conf.d/site.conf"
location = /old-pricing { return 301 /pricing; }
location /docs/v1/ { rewrite ^/docs/v1/(.*)$ /docs/$1 permanent; }
```

```text title="public/_redirects"
/old-pricing   /pricing   301
/docs/v1/*     /docs/:splat   301
```

```json title="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

- [ ] Open a made-up URL on your site and confirm it answers with a 404 status, not a 200 or a redirect.
- [ ] Check that the 404 page loads your analytics script, including on a host's or CDN's own error page.
- [ ] Send one event per 404 with `path` and `referrer`, both without query strings.
- [ ] In a single-page app, report from the catch-all route, take the referrer from the router and add `noindex`.
- [ ] Run the log one-liner once a week, and read Search Console's Not found (404) and Soft 404 rows.
- [ ] Fix every 404 whose referrer is one of your own pages first; those links are yours to change.
- [ ] Redirect moved pages one to one, and leave deleted pages as 404s.
- [ ] For crawlers with `@invisitdev/server`, record the status after your app answers, not in `handle()`.

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.
