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:
<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:
<!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:
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 = () => previousimport { 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>
)
}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 /> },
])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:
- 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 withReferrer-Policy: no-referrer. Typed and bookmarked addresses send none.
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:
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 -30It 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:
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 - crawlerThe 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:
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):
location = /old-pricing { return 301 /pricing; }
location /docs/v1/ { rewrite ^/docs/v1/(.*)$ /docs/$1 permanent; }/old-pricing /pricing 301
/docs/v1/* /docs/:splat 301{
"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
pathandreferrer, 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 inhandle().
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.
