Locate Website Visitors in Next.js with IP and Supabase

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.

The columns of our views table

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:

A diagram of the different requests to the server and database

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

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.

Felix

Felix

Last edited:

Last edited:

F

More posts

Cover Image for Making the Internet More Human

Making the Internet More Human

Navigating the internet has become difficult with all the accessibility issues, pop-ups, cookie banners and advertisements. We’ll explore different ways to make the web a more welcoming place for humans.

Cover Image for Designing spring animations for the web

Designing spring animations for the web

Designing intuitive animations can be tricky. We’ll explore movement in the physical world and see how to replicate this digitally, as well as best practices for responsive animation.