A refreshed look is coming your way soon
Logo
Overview
Build your own last visitor indicator with Vercel & Redis

Build your own last visitor indicator with Vercel & Redis

June 15, 2025
10 min read

What are we building?

Ever visited a website and seen something like “Last visitor from Tokyo, Japan” and thought “wow, that’s cool”? Today we’re building exactly that. A simple indicator that shows where your last visitor came from, complete with a flag emoji for visual appeal.

It’s similar to a guestbook, but instead of people signing their names, the system automatically captures their location. All data is anonymous and only shows city and country information.

Concept behind it

If you use Vercel, it automatically forwards useful headers whenever someone makes a request to your site. These headers contain location information about each request.

The two headers we care about are:

  • x-vercel-ip-city: This is the city name of the request. For example, “New Delhi” or “San Francisco”
  • x-vercel-ip-country: This is the country code of the request. For example, “IN” for India or “US” for United States

When someone visits your site, we extract these headers, validate them, and store them in Redis. The next person who visits sees where the previous visitor was from. Each visitor becomes the “last visitor” for the next one.

Tools we need

The idea is simple: show the “last viewer” location when someone loads your site. But we need somewhere to store this information temporarily. Redis is an in-memory storage solution that’s well-suited for this use case.

Redis is perfect for this because:

  • It’s fast and efficient
  • It’s ephemeral (we don’t need to store this forever)
  • It works great with serverless functions (which is what we’re using)

When a page loads, we:

  1. Get the current visitor’s city and country from Vercel headers
  2. Check what’s already stored in Redis (the last visitor)
  3. If it’s different (or nothing exists), we update Redis
  4. Return the previous visitor’s location to display

We only write to Redis if the location changed, which saves us an extra write operation and improves efficiency.

Getting the Tools configured

Setting up Redis instance

First things first, we need a Redis instance. Upstash is perfect for this because it’s serverless, has a generous free tier, and works well with Vercel.

Here’s what you need to do:

  1. Head over to Upstash and sign up (or log in if you already have an account)
  2. Create a new Redis database
  3. Choose a region close to your Vercel deployment
  4. Once created, you’ll get two important things:
    • UPSTASH_REDIS_REST_URL - This is your Redis endpoint URL
    • UPSTASH_REDIS_REST_TOKEN - This is your authentication token

Save these somewhere safe. We’ll need them for the next step.

Vercel

If you’re reading this article, chances are you already have a Vercel account. But just in case you don’t:

  1. Go to Vercel and sign up
  2. Import your project (or create a new one)
  3. Make sure your project is deployed

That’s it! Vercel will automatically inject those location headers we talked about earlier. No additional configuration is needed.

It’s time to cook some code

Let’s create an API route that handles fetching and storing the visitor location. I’m using Astro here, but this will work with any framework that supports API routes (Next.js, SvelteKit, and others).

First, install the Upstash Redis client:

Terminal window
npm install @upstash/redis

Now, let’s create our API route. I’ll put mine at src/pages/api/live-location.ts:

import type { APIRoute } from 'astro'
import { Redis } from '@upstash/redis'
export const prerender = false
type LastVisitorLocation = {
city: string
country: string
}
const LOCATION_KEY = 'lastVisitorLocation'
const CITY_HEADER_NAME = 'x-vercel-ip-city'
const COUNTRY_HEADER_NAME = 'x-vercel-ip-country'
// Initialize Redis client
const redisUrl = import.meta.env.KV_REST_API_URL
const redisToken = import.meta.env.KV_REST_API_TOKEN
const redis =
redisUrl && redisToken
? new Redis({
url: redisUrl,
token: redisToken,
})
: null
export const GET: APIRoute = async ({ request }) => {
if (!redis) {
return new Response(
JSON.stringify({ error: 'Redis client not initialized' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } },
)
}
const headersList = request.headers
const city = headersList.get(CITY_HEADER_NAME)
const country = headersList.get(COUNTRY_HEADER_NAME)
// Get the last visitor location from Redis
const lastLocation = await redis.get<LastVisitorLocation>(LOCATION_KEY)
// Update Redis if we have new location data
if (city && country) {
const newLocation = { city, country }
// Only update if location changed (saves a write!)
if (
!lastLocation ||
lastLocation.city !== city ||
lastLocation.country !== country
) {
await redis.set(LOCATION_KEY, newLocation, { ex: 60 * 60 * 24 * 7 }) // 7 days TTL
}
}
// Return the last visitor (before current update)
return new Response(
JSON.stringify({
lastVisitor: lastLocation || null,
currentVisitor: city && country ? { city, country } : null,
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
},
)
}

There we go! A simple API route that:

  1. Gets the current visitor’s location from Vercel headers
  2. Retrieves the last visitor’s location from Redis
  3. Updates Redis if the location changed
  4. Returns the last visitor’s location

Notice how we’re using ex: 60 * 60 * 24 * 7 in the set command? That sets an expiration of 7 days. After that, Redis will automatically delete the key, keeping the database clean.

Also, we only write to Redis if the location actually changed. This prevents unnecessary writes when the same person refreshes the page multiple times.

Now let’s harden it!

Our code works, but we should add security measures to protect against malicious input. Let’s add validation, rate limiting, and proper error handling.

// Security constants
const MAX_CITY_LENGTH = 100
const MAX_COUNTRY_LENGTH = 2 // ISO country codes are 2 letters
const RATE_LIMIT_WINDOW = 60 // seconds
const RATE_LIMIT_MAX_REQUESTS = 10 // max requests per window
const LOCATION_TTL = 60 * 60 * 24 * 7 // 7 days
// Decode URL-encoded header values
const decodeHeaderValue = (value: string | null): string | null =>
value ? decodeURIComponent(value) : null
// Sanitize and validate city name
function sanitizeCity(city: string | null): string | null {
if (!city) return null
const sanitized = city
.trim()
.replace(/[\x00-\x1F\x7F]/g, '') // Remove non-printable chars
.slice(0, MAX_CITY_LENGTH)
// Only allow letters, spaces, hyphens, apostrophes, and common punctuation
if (!/^[a-zA-Z\s\-'.,()]+$/.test(sanitized)) {
return null
}
return sanitized || null
}
// Validate and sanitize country code
function sanitizeCountry(country: string | null): string | null {
if (!country) return null
const sanitized = country
.trim()
.replace(/[\x00-\x1F\x7F]/g, '')
.toUpperCase()
// Must be exactly 2 uppercase letters (ISO 3166-1 alpha-2)
if (!/^[A-Z]{2}$/.test(sanitized) || sanitized.length > MAX_COUNTRY_LENGTH) {
return null
}
return sanitized
}
// Rate limiting using Redis
async function checkRateLimit(
redis: Redis,
identifier: string,
): Promise<boolean> {
const key = `rate_limit:${identifier}`
try {
const count = await redis.incr(key)
// Set expire only on first increment
if (count === 1) {
await redis.expire(key, RATE_LIMIT_WINDOW)
}
return count <= RATE_LIMIT_MAX_REQUESTS
} catch {
// If rate limiting fails, allow the request (fail open)
return true
}
}
// Get client identifier for rate limiting
function getClientIdentifier(request: Request): string {
const headers = request.headers
const ip =
headers.get('x-vercel-forwarded-for')?.split(',')[0] ||
headers.get('x-forwarded-for')?.split(',')[0] ||
headers.get('cf-connecting-ip') ||
headers.get('x-real-ip') ||
'unknown'
return ip.trim()
}

Now let’s update our GET handler to use these security measures:

export const GET: APIRoute = async ({ request }) => {
try {
if (!redis) {
return new Response(
JSON.stringify({ error: 'Redis client not initialized' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } },
)
}
// Rate limiting
const clientId = getClientIdentifier(request)
const allowed = await checkRateLimit(redis, clientId)
if (!allowed) {
return new Response(JSON.stringify({ error: 'Too many requests' }), {
status: 429,
headers: {
'Content-Type': 'application/json',
'Retry-After': String(RATE_LIMIT_WINDOW),
},
})
}
const headersList = request.headers
const rawCity = decodeHeaderValue(headersList.get(CITY_HEADER_NAME))
const rawCountry = decodeHeaderValue(headersList.get(COUNTRY_HEADER_NAME))
// Sanitize and validate inputs
const city = sanitizeCity(rawCity)
const country = sanitizeCountry(rawCountry)
// Get last visitor location from Redis
const location = await redis.get<LastVisitorLocation>(LOCATION_KEY)
// Update Redis if location has changed and is valid
if (
city &&
country &&
(!location || city !== location.city || country !== location.country)
) {
await redis.set(LOCATION_KEY, { city, country }, { ex: LOCATION_TTL })
}
// Return the last visitor location (before current update)
return new Response(
JSON.stringify({
lastVisitor: location || null,
currentVisitor: city && country ? { city, country } : null,
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
},
)
} catch (error) {
return new Response(
JSON.stringify({
error:
error instanceof Error ? error.message : 'Unknown error occurred',
}),
{
status: 500,
headers: { 'Content-Type': 'application/json' },
},
)
}
}

What we’ve added:

  • Input validation: We sanitize city and country inputs to prevent injection attacks
  • Rate limiting: We limit requests to 10 per minute per IP address
  • Error handling: Proper try-catch blocks with meaningful error messages
  • Header decoding: Vercel headers are URL-encoded, so we decode them properly

Now our API is much more secure with proper input validation and rate limiting in place.

Here comes the frontend

Now that we have our API working, let’s build a React component to display this information. We’ll include a flag emoji and smooth animations for a polished user experience.

'use client'
import { useState, useEffect } from 'react'
interface LiveLocationData {
message?: string
emoji?: string
state?: 'found' | 'awol'
}
// Convert country code to flag emoji
function getCountryFlagEmoji(countryCode: string): string {
if (!countryCode || countryCode.length !== 2) {
return '🌍'
}
// Convert 2-letter country code to flag emoji
const codePoints = countryCode
.toUpperCase()
.split('')
.map((char) => 0x1f1e6 + char.charCodeAt(0) - 65)
if (codePoints.some((cp) => cp < 0x1f1e6 || cp > 0x1f1ff)) {
return '🌍'
}
return String.fromCodePoint(...codePoints)
}
async function getLocation(): Promise<LiveLocationData> {
const res = await fetch('/api/live-location')
if (!res.ok) {
throw new Error('Failed to fetch location')
}
const data = await res.json()
const lastVisitor = data?.lastVisitor
const isFound = !!lastVisitor && lastVisitor.city && lastVisitor.country
let emoji: string | undefined
let message: string | undefined
if (isFound) {
emoji = getCountryFlagEmoji(lastVisitor.country)
message = `Last visitor from ${lastVisitor.city}, ${lastVisitor.country}`
} else {
message = 'Location unknown'
}
return {
emoji,
message,
state: isFound ? 'found' : 'awol',
}
}
interface LiveLocationProps {
delay?: boolean
size?: 'sm' | 'md'
className?: string
}
export function LiveLocation({
delay = false,
size = 'md',
className = '',
}: LiveLocationProps) {
const [location, setLocation] = useState<LiveLocationData | null>(null)
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
// Skip fetching in dev mode
const isDev = import.meta.env.DEV
if (isDev) {
setIsLoading(false)
return
}
let mounted = true
const loadLocation = async () => {
try {
const data = await getLocation()
if (mounted) {
setLocation(data)
setTimeout(() => {
if (mounted) {
setIsLoading(false)
}
}, 500)
}
} catch (error) {
console.error('Failed to load live location:', error)
if (mounted) {
setIsLoading(false)
}
}
}
if (delay) {
setTimeout(loadLocation, 100)
} else {
loadLocation()
}
return () => {
mounted = false
}
}, [delay])
return (
<p className={`text-muted-foreground inline-flex items-center gap-2 ${className}`}>
{isLoading ? (
<span>Locating…</span>
) : (
<>
{location?.emoji && <span>{location.emoji}</span>}
<span>{location?.message || 'Location unknown'}</span>
</>
)}
</p>
)
}

This component:

  1. Fetches the location data from our API
  2. Converts the country code to a flag emoji for visual appeal
  3. Shows a loading state while fetching
  4. Handles errors gracefully

You can customize the styling however you want. Add animations, change colors, or adjust the layout to match your design.

Final steps

Now that we have everything working, let’s deploy it! Here’s what you need to do:

  1. Add environment variables to Vercel:

    • Go to your Vercel project settings
    • Navigate to “Environment Variables”
    • Add KV_REST_API_URL with your Upstash Redis URL
    • Add KV_REST_API_TOKEN with your Upstash Redis token
    • Make sure to add them for Production, Preview, and Development environments
  2. Deploy your project:

    Terminal window
    git add .
    git commit -m "Add last visitor indicator"
    git push

    Vercel will automatically deploy your changes.

  3. Test it out:

    • Visit your deployed site
    • Open it in an incognito window (or ask a friend to visit)
    • You should see the location indicator appear!

If you want to test locally, you can add the environment variables to a .env file:

KV_REST_API_URL=https://your-redis-url.upstash.io
KV_REST_API_TOKEN=your-token-here

Just make sure not to commit this file to Git (add it to your .gitignore). This prevents accidentally exposing your credentials.

Outro

And that’s it! You now have a working last visitor indicator on your site. It’s a fun little feature that adds personality to your website and gives visitors a sense of community (even if they’re just random internet strangers).

Some ideas for extending this:

  • Add a history of recent visitors (store multiple locations)
  • Show visitor count per country
  • Add a map visualization
  • Make it update in real-time with WebSockets

There are many ways you can extend this feature.

If you run into any issues or have questions, feel free to reach out. And if you build something cool with this, I’d love to see it!

Happy coding! 🚀