Building a Live Analytics Dashboard with Vercel Drains and Supabase
What I Built
The portfolio site has an analytics dashboard at /analytics. It's supposed to show live traffic data as a proof-of-capability. One problem: it was showing all zeros. Every metric, every chart. Just zeros and a "COLLECTING DATA..." message.
I wired it up for real. Vercel Analytics Drains push raw pageview events to a custom endpoint, Supabase stores them, and the dashboard queries and aggregates on each page load.
The Problem
The analytics page looked good but the function behind it was a stub:
export async function getSiteAnalytics(): Promise<SiteAnalytics> {
// TODO: Once Vercel API access is confirmed, implement
return {
pageViews: 0,
uniqueVisitors: 0,
topPages: [],
referrers: [],
viewsOverTime: [],
};
}
Every number hardcoded to zero. There was even a "Google Ads ROI - COMING SOON" placeholder. Not exactly the live proof-of-capability I was going for.

How I Built It
Vercel has a feature called Analytics Drains. You configure a webhook URL, and Vercel sends raw analytics events to it as POST requests. I already had Supabase wired up for the contact form, so adding a page_views table was trivial. Three pieces: ingest, store, query.
The ingest endpoint at /api/analytics/ingest authenticates requests with a shared secret using crypto.timingSafeEqual, validates each event, normalizes paths (stripping query strings and trailing slashes), and upserts into Supabase. A unique index on (timestamp, session_id, path) handles dedup so retries are idempotent.
On the read side, getSiteAnalytics() queries the last 30 days from Supabase and aggregates in TypeScript: total page views, unique visitors by device ID, top pages, referrer domains, and a daily time series. The page revalidates every 5 minutes.
The Interesting Gotcha
The unique index on (timestamp, session_id, path) was supposed to prevent duplicate rows when Vercel retries a failed delivery. But some events arrive without a session ID. In Postgres, NULL != NULL, which means two rows with a NULL session_id are considered distinct by the unique index. Every anonymous pageview could be inserted multiple times on retry.
The fix: default session_id to -1 when it's missing. Simple, but the kind of thing you only catch if you think carefully about Postgres NULL semantics.
I also switched the page from Promise.all to Promise.allSettled so a failing analytics query doesn't wipe out the contact form metrics, and made the query function throw real errors instead of silently returning zeros. If the dashboard shows zeros, I want to trust that the number is actually zero.
The Result

The dashboard shows real traffic now. Page views, unique visitors, a time series chart with actual data points, top pages with bar visualization, and traffic sources broken down by referrer domain.
The whole thing runs on Vercel's free tier and Supabase's free tier. No additional infrastructure, no third-party analytics service, no cookies.
What I Learned
The biggest lesson is about NULLs. I've been writing SQL for years and I still got bitten by NULL comparison semantics in a unique index. It's the kind of bug that doesn't show up in testing because your test data probably has session IDs. It shows up in production when real anonymous users hit your site.
The other takeaway is about error handling honesty. Returning default values on error feels defensive, but it actually hides problems. If your dashboard shows zeros, you should be able to trust that number. Errors should look like errors.
There's plenty more I want to add here, like tracking referrer campaigns and maybe some conversion metrics. But the foundation is solid and the data is flowing. Good enough to ship, good enough to build on.