Toby Allen

Server-Side Analytics for a Next.js Blog with Vercel Blob

· Security Industry, Careers and other Domains, Vercel

Most analytics tools require a JavaScript snippet in every page. Google Analytics, Plausible, Fathom — all of them instrument the browser in some way, which means your readers' browsers are doing the tracking work, adding a dependency you cannot fully control, and in some cases reporting to a third party that has its own data practices. For a personal technical blog this felt like more than was needed.

The alternative is to track page views entirely server-side: intercept each incoming request, record what you need, and never touch the browser at all. This approach also handles readers who have JavaScript disabled, use aggressive ad blockers, or are browsing with privacy tools that suppress analytics payloads — which, given the audience of a security and identity blog, is not a small percentage.

In this post I will detail how I added server-side page view tracking to this blog using Next.js 16's proxy layer and Vercel Blob, with no client-side JavaScript, no third-party service, and one new dependency.

The steps we will cover in this post are as follows.

  1. Intercepting page requests with proxy.ts
  2. Writing page view records to Vercel Blob
  3. Encoding analytics data into the blob pathname
  4. Building a server-rendered /stats page

Intercepting Page Requests with proxy.ts

Next.js 16 renamed middleware.ts to proxy.ts. The function is now named proxy rather than middleware, and a codemod is available to migrate existing files. The behaviour is otherwise the same: it intercepts requests before they reach your application routes, runs on the Node.js runtime by default, and has access to the full request headers.

The key to making analytics non-blocking is event.waitUntil(). This schedules work to run after the response has been sent to the client — the request completes at normal speed and the blob write happens in the background.

export const proxy: NextProxy = (req: NextRequest, event: NextFetchEvent) => {
  const country = req.headers.get('x-vercel-ip-country')
  const ua = req.headers.get('user-agent')
 
  event.waitUntil(logPageView(req.nextUrl.pathname, country, ua))
 
  return NextResponse.next()
}

Vercel injects the visitor's country code as x-vercel-ip-country on every request — no GeoIP library needed, it is just a header.

The matcher excludes everything that is not a real page visit: Next.js static assets, image optimisation routes, the favicon, sitemap files, the /stats page itself, and — importantly — prefetch requests. Next.js fires prefetch requests in the background when the user hovers over a link, and counting those as page views would inflate the numbers considerably.

export const config = {
  matcher: [
    {
      source: '/((?!api|_next/static|_next/image|favicon|icon\\.svg|sitemap|robots|stats).*)',
      missing: [
        { type: 'header', key: 'next-router-prefetch' },
        { type: 'header', key: 'purpose', value: 'prefetch' },
      ],
    },
  ],
}

Writing Page View Records to Vercel Blob

Vercel Blob is an object store that integrates directly with Vercel deployments. It picks up BLOB_READ_WRITE_TOKEN from the environment automatically, so there is no client configuration needed beyond installing @vercel/blob.

The bot filter runs before any write. It checks the User-Agent string against a list of known crawler signatures — bot, crawl, spider, slurp, and a handful of specific ones like semrushbot and ahrefsbot. This is imperfect but it removes the majority of automated traffic from the dataset.

const BOT_SIGNATURES = [
  'bot', 'crawl', 'spider', 'slurp', 'curl/', 'wget/',
  'semrushbot', 'ahrefsbot', 'facebookexternalhit', 'twitterbot',
]
 
function isBot(ua: string): boolean {
  const lower = ua.toLowerCase()
  return BOT_SIGNATURES.some(sig => lower.includes(sig))
}

Encoding Analytics Data into the Blob Pathname

This is the part of the design I found most useful. The obvious approach is to store each page view as a JSON blob and then fetch and parse all of them to build the stats page. That works, but for a page that might need to aggregate thousands of entries it is slow and expensive — you pay for every blob read.

The alternative is to encode everything you need into the blob pathname and never read the blob contents at all. The list() function returns blob metadata — pathname, size, upload time — without fetching the body. If the pathname contains the analytics data, list() is all you need.

The pathname format I used is:

analytics/{YYYY-MM-DD}/{encodedPath}/{country}/{device}/{timestamp}.txt

For example, a desktop visitor from Australia reading the home page on 21 June 2026 produces:

analytics/2026-06-21/_root_/AU/d/1750000000000.txt

And an article page:

analytics/2026-06-21/articles~server-side-analytics/AU/d/1750000000001.txt

The forward slashes in the URL path are replaced with ~, and the homepage gets the special token _root_ to avoid an empty segment. The blob content is literally the string 1 — one byte, never read.

The stats page queries one prefix per day for the window it wants to display. Running the queries in parallel with Promise.all() keeps the response time acceptable even for a two-week window:

await Promise.all(
  dates.map(async (date) => {
    const result = await list({ prefix: `analytics/${date}/`, limit: 1000 })
    for (const blob of result.blobs) {
      const parts = blob.pathname.split('/')
      // parts: ['analytics', date, encodedPath, country, device, timestamp]
      const path = decodePath(parts[2])
      const country = parts[3]
      const device = parts[4]
      // accumulate totals...
    }
  })
)

No per-blob fetches. The entire aggregation runs off the listing metadata.

Building the /stats Page

The /stats page is a Next.js server component with export const dynamic = 'force-dynamic'. It calls getStats(14) on every load, which runs the parallel list() queries described above and returns totals: page views by day, top pages, country breakdown, and a desktop/mobile/tablet split.

The bar charts are pure CSS — a <div> with a percentage width set inline. No charting library, no client JavaScript.

function Bar({ value, max }: { value: number; max: number }) {
  const pct = max > 0 ? Math.round((value / max) * 100) : 0
  return (
    <div className="flex-1 h-2 bg-[var(--border)] rounded-full overflow-hidden">
      <div className="h-full bg-[var(--accent)] rounded-full" style={{ width: `${pct}%` }} />
    </div>
  )
}

The page is at /stats and is not linked from the navigation — it is just for my own reference.

Conclusion

The full implementation is in proxy.ts and lib/analytics.ts in this site's repository. The pathname-as-data approach is the piece I would reuse elsewhere — it works well whenever you want fast aggregation over a large number of small records and the schema is simple enough to fit in a path.