What this site's traffic actually shows since the move to Next.js
The stats page on this site has existed for a while, but it only ever answered one question: what happened in the last 14 days. That is a fine window for "did last week's post get read", but it is a terrible window for "what does this blog actually do well", because two weeks of a site with this little daily traffic is mostly noise. A quiet fortnight looks the same as a genuinely unpopular topic.
I rebuilt the stats backend so it keeps a running lifetime total for every page, on top of the existing 14-day view, without scanning years of daily records to get there - each page view now increments both the day's own counter and a permanent per-page counter at write time, trading a small amount of extra write work for all-time numbers that are always a single cheap read away rather than a summation job. In this post I will go through what changed on the stats page itself, what the all-time numbers actually show once there was more than a fortnight to look at, and a bot-detection fix the numbers themselves ended up exposing.
What changed on /stats
The page now leads with an all-time total and a traffic-over-time chart with a range toggle - 7, 14, 30, or 90 days - instead of a chart fixed at a single window. Underneath that, the existing top pages, top countries, and device breakdown sections now show twice: once for all time, once for the last 14 days, so a short-term spike and a long-term favourite are both visible on the same page instead of one silently overwriting the other.
Two smaller fixes came along with it. Country names were previously a hand-maintained lookup table with a dozen or so entries, so anything outside that list - Finland, as it happened - rendered as a bare two-letter code. That is now Intl.DisplayNames, a built-in that renders region names from standard codes without a lookup table to maintain. And the day-bucketing logic that decides which calendar day a page view belongs to had a latent inconsistency between the write path and the read path - one mutated a local-time Date object before formatting it, the other was consistently UTC - which I fixed while the file was already open for the bigger change.
The top five, and a pattern I didn't expect
Once there was more than a fortnight of history to look at, the top five articles by page views since the Next.js migration were these.
- Auth0 Session and Token Management: Every Option Explained
- Continuous Access Evaluation for Auth0: Building a CAEP/SSF Reference Demo
- Building a Live Auth0 Token and Session Demo
- Anonymous to Known: Stitching Pre-Auth Browsing Sessions with PostHog and Auth0
- Native-to-Web SSO with Auth0: How the Session Transfer Token Works
Four of those five - everything except the CAEP/SSF post - belong to the same session and token management series, and by average views per article rather than raw totals, that series currently outperforms every other series on the blog: 366 views per article across its 14 parts, ahead of the next best. That average hides how unevenly traffic is spread within the series and doesn't account for how much longer some parts have had to accumulate views than others, but the gap over every other series is wide enough that I don't think that alone explains it. I did not set out to write the most popular thing on this domain by writing fourteen posts about session and token mechanics, but the numbers currently say that is what happened.
The tag breakdown tells a similar story from a different angle. Auth0 and Customer Identity Products are, unsurprisingly, the two most-viewed tags on the site, given how much of the archive is Auth0-specific. What surprised me more was Human in the Loop - the newest section on the site, covering posts written first-person about actually building things with Claude Code, bugs and all - already averaging more views per article than the blog's long-run standard-voice average, on a fraction of the post count. It is early days for that section and the sample size is small enough that novelty traffic or timing could explain part of it, but it is still a real early signal worth paying attention to as more posts land in that section.
An honest caveat, and a bonus finding
The all-time totals only cover traffic since this site moved off WordPress and onto its current Next.js setup, not the site's full history - the analytics system was built for this codebase, and it has no data from the old hosting to backfill against. Any post that was popular under the old setup and hasn't been read much since the move will look quiet here, not because it stopped mattering but because the counter never saw its earlier traffic. So individual rankings, especially for anything that predates the move, aren't directly comparable to how a post did on WordPress.
One more thing turned up in the top-pages list that had nothing to do with popularity: a cluster of paths like /wp, /wordpress, /old, /backup, and /new, each with over a hundred recorded hits. /wp and /wordpress are obviously WordPress-specific; /old, /backup, and /new are more likely generic admin-panel and stale-CMS probing that happens to land on the same domain, not necessarily the same scanner run. Either way they slipped past the old bot-detection filter because it relied on matching known bot substrings in the user agent string, which is a poor discriminator against a scanner sending an entirely ordinary browser UA. It was not urgent - they are 404s, not a security hole - but it was a reminder that "no tracking scripts, server-side only" analytics still count noise unless you are specifically filtering for it.
That filter was a 20-entry hand-rolled list I had been meaning to replace for a while, so I did. Before writing any code, I had three models from different families - gpt-5.4, gemini-3.1-pro-preview, and o3 - critique the planned fix and look for failure modes. All three raised the same core problem with my original plan: swapping in a better user-agent matching library was not going to fix the actual problem I had. A scanner probing /wp-admin.php can send an entirely ordinary Chrome user agent string. Better UA-string matching alone doesn't reliably catch that, because there is nothing wrong with the string itself - the tell is that the path does not correspond to anything this site actually serves.
So the real fix ended up being three separate, cheap layers rather than one clever one:
isbotreplaced the hand-rolled substring list - a genuine improvement for honest bots and crawlers that self-identify in their UA, but not a fix for the scanner problem on its own.- Route validation is the actual fix for the
/wp-style pollution: every real page this site serves is a small, fully enumerable set of routes, so a page view is now only counted if the path actually matches one of them. This is route-shape matching, not a naive string check, plus one extra rule specific to this site's content pipeline: a real slug like/articles/my-postmatches, but so would a scanner's/articles/wp-admin.phpif the shape check stopped there - since every real slug on this site comes from a kebab-case title and never contains a dot, rejecting any dot in that position closes that gap. - Fetch Metadata headers (
Sec-Fetch-Dest,Sec-Fetch-Mode) catch a different, cheaper class of junk. Most modern browsers send these automatically on a real navigation, and naive scripts usually don't, so a request missing them entirely - not just carrying the wrong UA - is treated as not worth counting before it reaches any of the checks above. This only affects whether the hit gets counted as a page view, not whether the request is actually served; nobody's page load breaks over a missing header.
One of the three reviews flagged a specific risk before I wrote a line of code: a scanner hitting /articles/wp-admin.php would match the shape of a real article URL closely enough to pass. I built the fix anyway with that gap still in it, because the risk was easy to agree with in the abstract and easy to forget once I was writing the actual matching logic. My own test suite caught it the moment I ran it, before any of this ever touched real traffic - a . in a slug segment is never legitimate on this site, so that is now an explicit rejection rather than an oversight. Since none of this had a UA sample to check against - the old filter never stored the raw string, only a derived device category - I also added a small log of rejected requests going forward, sampled by a hash of the path so the same junk path doesn't get logged on every single hit but a real signal still accumulates over time. There is finally something to audit the next time this needs tightening further.
Next Steps
The stats page itself is at tobytes.com/stats if you want to poke around the current numbers directly. The junk /wp-style paths already counted in the all-time totals predate this fix and will not self-correct - that is a follow-up cleanup, not something the new filter can retroactively repair.