Skip to content
M.Infath
cd ~/blog
~/blog/visitor-emails-geolocation-bot-filtering.mdx
22 Aug 2026 4 min readnextjsserverlessresendanalytics

I get an email every time someone visits my portfolio

I didn't want Google Analytics on my portfolio. It felt like too much for a five page site, and I know myself, I would never open the dashboard. What I actually wanted was much simpler: an email when someone visits, with a bit of context about who and where from.

So I built that. A small beacon on the client, one API route, an IP lookup, and Resend to send the email. No cookies, no analytics script, nothing stored in a database.

The client part#

A tiny component in the root layout sends a beacon on each page view:

VisitorTracker (the interesting part)
const payload = JSON.stringify({
  path: pathname,
  referrer: document.referrer,
  screen: `${window.screen.width}x${window.screen.height}`,
  dpr: window.devicePixelRatio,
});
navigator.sendBeacon('/api/track', new Blob([payload], { type: 'application/json' }));

I use sendBeacon because it doesn't block anything and it still goes through if the tab closes. sessionStorage makes sure each page only reports once per visit, and the whole thing is disabled on localhost so I don't email myself while developing.

The payload is deliberately small. I started with more fields (timezone, query params) and trimmed it back to only what I actually read: the screen size is there because the server can't get it reliably on its own.

The server part#

The API route does four things, in this order:

  1. validate the body with zod
  2. drop obvious bots by user agent (crawlers, link preview fetchers, uptime monitors)
  3. allow one email per IP per 30 minutes, so someone reading five pages doesn't send five emails
  4. look up the IP, build the email, send it with Resend

The email itself contains exactly five things: the page, the approximate city-level location, the referrer, the device with screen size, and the time. The IP address is only used for the lookup, the bot check and the dedupe window, then dropped. It never goes into the email and is never stored anywhere.

The lookup is one fetch:

The lookup
const res = await fetch(`http://ip-api.com/json/${encodeURIComponent(ip)}?fields=status,country,regionName,city,proxy,hosting`, {
  signal: AbortSignal.timeout(2500),
});

ip-api returns the city level location plus two flags: proxy and hosting. I added the flags without thinking much about them. They ended up being the most useful part.

If the lookup fails or times out, the email still goes out with location "Unknown". I didn't want tracking to break because a third party API had a bad day.

My first visitor was a bot#

The first notification I ever got said: Unknown browser on Unknown OS, from an address I didn't recognise. I was excited for about ten seconds. Then I looked the address up. OVH datacenter in Montreal. A crawler, not a person.

Real people connect from home or mobile ISPs. Scanners run on rented servers in datacenters. So the rule became:

// Datacenter IP with no proxy signal: a scanner, not a person on a VPN.
isLikelyBot: Boolean(data.hosting) && !data.proxy,

The !proxy part matters. Commercial VPNs also run in datacenters, but those IPs carry the proxy flag, and someone on a VPN is still a real visitor. Pure datacenter traffic gets a 204 and no email.

Since that change my inbox has been quiet unless an actual person shows up.

Things to know if you copy this#

  • The 30 minute dedupe map lives in memory, and serverless instances don't share memory. A cold start resets it. The sessionStorage check on the client covers most of it, and for a portfolio that is fine. The proper fix would be a small KV store.
  • IP location is city level at best. It points at the ISP's hub, not at the person. Mobile IPs can be off by a whole region.
  • On privacy, I kept this minimal on purpose. No cookies, no fingerprinting, no profile building, no IP in the email. The only place the visit exists is the email itself, which I read and delete. Also worth knowing: ip-api's free tier is for non-commercial use, which a personal portfolio is.

Was it worth it#

Yes. A dashboard would show me a number once a week. An email that says someone in Colombo just read my resume feels different. And since I'm open to freelance work, it's not just a nice feeling. When one of those arrives, I go check LinkedIn.