Locate Website Visitors in Next.js with IP and Supabase
Table of contents
Using API routes with Next.js as well as an IP address geolocation service is an easy way to display the last visitor of a website. I wanted to use this in a widget on my website homepage. I got this idea after visiting Rauno Freiberg's website – he's a designer at Vercel and I admire his work.
First of all, I mapped out what needs to be done:
- Locating the user. There are a few different ways to locate a user on a website, I chose to go with the IP address. This isn't always extremely precise, but it's good enough for getting a rough location to display.
- Storing user locations in a database. There obviously needs to be something on the server side remembering locations of users to display to the next user.
- APIs for updating and retrieving the last user's location. We also need to think about the order of requests, to make sure that when a user visits the website and the last location is updated, he doesn't get his location back from the database but the location of the previous user.
I also had a few priorities for this project: speed and minimal server load. I don't want the last visitor feature to cause a significant increase in page loading times for the client, or to increase server actions beyond what is provided in the Vercel free tier. We'll have a look at how to do this in the best way possible.
Fetching the country from IP
As IP addresses change frequently, and the ranges get reallocated, it's difficult to know which range of addresses maps to which country. Luckily, MaxMind has a free IP lookup tool called GeoLite2, which they update frequently. It's more or less precise, but good enough for what we're trying to do.
After creating an account and getting a license key, getting the city and country from an IP address is quite easy using the `@maxmind/geoip2-node` NPM package. Here's some example code:
import { WebServiceClient } from '@maxmind/geoip2-node'; const MAXMIND_USER = '1234567'; const MAXMIND_LICENSE = 'Your license here'; const ip = '43.247.156.26'; const client = new WebServiceClient(MAXMIND_USER, MAXMIND_LICENSE, {host: 'geolite.info'}); const response = await client.city(ip); const data = {country: response.country ? response.country.names.en : null, city: response.city ? response.city.names.en : null} console.log(data)
I added some extra logic after noticing that not all IP addresses map to specific cities in the lookup tool.
Implementing the database
To simplify things in the database, I decided to create a table with ip
, city
and country
columns. Setting the new country is equivalent to adding a new row, instead of updating the last row. This to make it easier to handle the location of the previous user/current user. There's a primary key, ID
, which will be automatically set.
I decided to go with Supabase, as I've already used their services and am impressed with the ease of use and the excellent Next.js integration.
I created a country table, and I disabled Row-Level-Security as this would require some form of authentication, and we're going to do all the database queries on the server-side as you'll see next, so the client will not have access to the database.
Adding the middleware and APIs
In order to fully separate the user from the database, I decided to perform all database queries on the server, and handle the country updating logic using Next.js middleware. This has the added benefit of not sending any additional client requests to log the users' country, which lightens the client-side stack.
To retrieve the country, instead of using getServerSideProps
, which would make each page dynamic and block it from loading until the database has responded, we do this on the client side through an API route. Need a refresher on Next.js page generation? Have a look at this post.
Let's map this out:
Middleware
Let's first look at the middleware: It makes a request to an API endpoint. For added security, we can prevent the client from interfering with the set-last-country
API by adding an API key: a secret that will be shared between the middleware and the API to ensure that only the middleware is able to update the country.
To prevent the same user from constantly setting their country as the last, for example if they navigate across multiple pages, we can set a session cookie from the middleware to prevent subsequent requests from triggering the country update.
import { NextRequest, NextResponse } from 'next/server' import { SITE_PATH, COUNTRY_SET_KEY, VIEW_SET_KEY } from '@/lib/constants' export const config = { matcher: ['/:path', '/posts/:path*', '/pages/:path*', '/creations/:path*'] } export async function middleware(req) { const res = NextResponse.next() //check if there is no cookie set if(!req.cookies.has('country')){ res.cookies.set('country', 'true') const forwarded = req.headers.get("x-forwarded-for") var ip = forwarded ? forwarded.split(/, /)[0] : req.headers.get("x-real-ip") try { fetch('https://' + req.headers.get('host') + '/api/set-last-country', { method: 'POST', body: JSON.stringify({ip, key: COUNTRY_SET_KEY}) }) } catch(error){ console.log(error) } } return res }
We're using the matcher to only trigger the middleware on certain routes, and the country cookie is used to check if the user's country has already been uploaded to the database during the current session. We then send the IP to the API, as well as the API key mentioned earlier.
You'll also notice that there's no await
key preceding the fetch, this speeds up loading times since it isn't necessary to wait for the database to update the last country in order to serve the page to the user.
API setter
In the set-last-country
API, we'll need to do the following:
- Use geolite2 to get a country and city from the IP address
- Store the country and city in the Supabase database.
We also need to make sure that the API only accepts POST requests, as it's the method the middleware uses for sending the data.
import { WebServiceClient } from '@maxmind/geoip2-node'; import { createClient } from '@supabase/supabase-js' import { COUNTRY_SET_KEY, MAXMIND_USER, MAXMIND_LICENSE } from '@/lib/constants'; export default async function handler(req, res) { if(req.method != 'POST'){ console.log("Unauthorized") return res.status(403).json({error: 'Unauthorized'}) } const body = JSON.parse(req.body) if(body.key != COUNTRY_SET_KEY){ console.log("Unauthorized") return res.status(403).json({error: 'Unauthorized'}) } //Get country from body.ip const client = new WebServiceClient(MAXMIND_USER, MAXMIND_LICENSE, {host: 'geolite.info'}); const response = await client.city(body.ip); const data = {country: response.country ? response.country.names.en : null, city: response.city ? response.city.names.en : null, ip: body.ip} //Send to SB const supabase = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY) var { error } = await supabase .from('visitors') .insert(data) res.status(200).json(data) }
API getter
The get-last-country
is the only API route we want to make available to the client. For this reason, we're not going to be using any API keys. To reduce overhead, caching can be used: since it doesn't really matter if we get the last user, or the 10th last user, we can cache the result for 10 minutes which will reduce server requests as well as database access. Caching on the client and server side can be done using special headers.
We also need to make sure that when the user visits the website and the last location is updated, he doesn't get his location back from the database but the location of the previous user. To do this, we can add an additional column in our country
table to check if the second to last row has been read or not. If it hasn't been read, we'll return that row, otherwise we'll return the last row. This ensures that the user never gets their location on the first visit, but they might on the next.
import { createClient } from '@supabase/supabase-js' export default async function handler(req, res) { res.setHeader('Vercel-CDN-Cache-Control', 'max-age=1200');//Cache for 20 minutes res.setHeader('CDN-Cache-Control', 'max-age=600');//Cache for 10 minutes res.setHeader('Cache-Control', 'max-age=600');//Cache for 10 minutes const supabase = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY) var { data, error } = await supabase .from('visitors') .select('id, country, city, read') .order('id', { ascending: false }) .limit(2) if(!data[1].read){ var { error } = await supabase .from('visitors') .update({read: true}) .eq('id', data[1].id) data = data[1] }else{ data = data[0] } delete data.read delete data.id res.status(200).json({cached: false, value: data}) }
Client-side logic
Now that we've implemented everything on the server side, all that's left to do is to create a component which will display the location of the last visitor:
import { useEffect, useState } from 'react' import styles from './lastvisitor.module.scss'; export default function LastVisitor(){ const [location, setLocation] = useState("") useEffect(() => { getLastVisitor() }, []) async function getLastVisitor(){ const res = await fetch('https://felixrunquist.com/api/get-last-country') if(res.status == 200){ const json = await res.json() setLocation((json.value.city ? json.value.city + ', ' : "") + json.value.country) } } return ( <div className={styles.container + ' ' + (location == '' ? styles.hidden : '')}> <p>Last visitor: {location}</p> </div> ) }
The last visitor element stays visually hidden until the data has been fetched.
Closing notes
That's it, we now have a working last visitor system! Since the priority here is to reduce server load, caching and other features are used to prevent too many requests from being made. This comes with a tradeoff: the last visitor might not always be accurate.
For additional safeguards against server usage, we could also implement rate limiting using @upstash/ratelimit
, as mentioned in Next.js documentation.
If you want to dig further into the different methods of generating content in Next.js, I've written an article which explains how getServerSideProps
, getStaticProps
and revalidate
work.
Have any questions? Feel free to send me a message on Twitter!
References
- Maxmind documentation, helpful for translating an IP address to a rough geographical location
- Rauno Freiberg's website with the last visitor counter is what got me inspired to create one!
- Josh W. Comeau's article on hit counters explains the integration of serverless functions in static sites
Credits to Big Loong's template which was used as a starting point for the article illustration. I made it with Spline, a tool I discuss in this article.