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:
- Get the current visitor’s city and country from Vercel headers
- Check what’s already stored in Redis (the last visitor)
- If it’s different (or nothing exists), we update Redis
- 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:
- Head over to Upstash and sign up (or log in if you already have an account)
- Create a new Redis database
- Choose a region close to your Vercel deployment
- Once created, you’ll get two important things:
UPSTASH_REDIS_REST_URL- This is your Redis endpoint URLUPSTASH_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:
- Go to Vercel and sign up
- Import your project (or create a new one)
- 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:
npm install @upstash/redisNow, 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 clientconst redisUrl = import.meta.env.KV_REST_API_URLconst 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:
- Gets the current visitor’s location from Vercel headers
- Retrieves the last visitor’s location from Redis
- Updates Redis if the location changed
- 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 constantsconst MAX_CITY_LENGTH = 100const MAX_COUNTRY_LENGTH = 2 // ISO country codes are 2 lettersconst RATE_LIMIT_WINDOW = 60 // secondsconst RATE_LIMIT_MAX_REQUESTS = 10 // max requests per windowconst LOCATION_TTL = 60 * 60 * 24 * 7 // 7 days
// Decode URL-encoded header valuesconst decodeHeaderValue = (value: string | null): string | null => value ? decodeURIComponent(value) : null
// Sanitize and validate city namefunction 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 codefunction 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 Redisasync 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 limitingfunction 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 emojifunction 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:
- Fetches the location data from our API
- Converts the country code to a flag emoji for visual appeal
- Shows a loading state while fetching
- 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:
-
Add environment variables to Vercel:
- Go to your Vercel project settings
- Navigate to “Environment Variables”
- Add
KV_REST_API_URLwith your Upstash Redis URL - Add
KV_REST_API_TOKENwith your Upstash Redis token - Make sure to add them for Production, Preview, and Development environments
-
Deploy your project:
Terminal window git add .git commit -m "Add last visitor indicator"git pushVercel will automatically deploy your changes.
-
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.ioKV_REST_API_TOKEN=your-token-hereJust 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! 🚀