Geolocation
This boilerplate uses IPinfo to detect user location based on their IP address, enabling features like dynamic pricing, localized content, and country-specific experiences.
Overview
The geolocation system provides:
- Country detection - Identify user's country from IP address
- EU detection - Check if user is in the European Union
- Currency mapping - Show appropriate currency based on location
- Privacy-focused - No personal data stored, IP-based only
- Server-side - Secure API key management
Configuration
Environment variables
Add your IPinfo API token to .env:
IPINFO_TOKEN="your_ipinfo_token"
API endpoints
Get IP information
export default defineEventHandler(async event => {
const config = useRuntimeConfig()
const ipinfoToken = config.ipinfoToken
if (!ipinfoToken) {
throw createError({
statusCode: 500,
message: 'IPinfo token not configured',
})
}
// Get client IP address
const clientIp = getClientIp(event)
if (!clientIp) {
return { country: 'US', isEU: false } // Default fallback
}
try {
const response = await fetch(`https://ipinfo.io/${clientIp}?token=${ipinfoToken}`)
const data = await response.json()
return {
ip: data.ip,
country: data.country, // Two-letter country code (e.g., 'US', 'DE')
city: data.city,
region: data.region,
isEU: isEUCountry(data.country),
}
} catch (error) {
logger.error('IPinfo error:', error)
return { country: 'US', isEU: false } // Fallback on error
}
})
Helper to check EU countries
const EU_COUNTRIES = [
'AT',
'BE',
'BG',
'HR',
'CY',
'CZ',
'DK',
'EE',
'FI',
'FR',
'DE',
'GR',
'HU',
'IE',
'IT',
'LV',
'LT',
'LU',
'MT',
'NL',
'PL',
'PT',
'RO',
'SK',
'SI',
'ES',
'SE',
]
export function isEUCountry(countryCode: string): boolean {
return EU_COUNTRIES.includes(countryCode.toUpperCase())
}
Dynamic pricing
Show appropriate currency and prices based on location:
Pricing page with currency detection
<script setup lang="ts">
const { data: ipInfo } = await useFetch('/api/ip/info')
const currency = computed(() => {
if (!ipInfo.value) return 'USD'
// Show EUR for EU countries, GBP for UK, USD for others
if (ipInfo.value.isEU) return 'EUR'
if (ipInfo.value.country === 'GB') return 'GBP'
return 'USD'
})
const prices = {
USD: {
monthly: 29,
yearly: 290,
},
EUR: {
monthly: 25,
yearly: 250,
},
GBP: {
monthly: 22,
yearly: 220,
},
}
const currentPrices = computed(() => prices[currency.value])
</script>
<template>
<div class="base-container py-8">
<h1 class="text-4xl font-bold mb-8">Pricing</h1>
<div class="grid md:grid-cols-2 gap-8">
<!-- Monthly plan -->
<Card>
<CardHeader>
<CardTitle>Monthly</CardTitle>
<div class="text-4xl font-bold">
{{ formatPrice(currentPrices.monthly, currency) }}
<span class="text-base font-normal text-muted-foreground">/month</span>
</div>
</CardHeader>
<CardContent>
<Button @click="handleCheckout('monthly')"> Subscribe </Button>
</CardContent>
</Card>
<!-- Yearly plan -->
<Card>
<CardHeader>
<CardTitle>Yearly</CardTitle>
<div class="text-4xl font-bold">
{{ formatPrice(currentPrices.yearly, currency) }}
<span class="text-base font-normal text-muted-foreground">/year</span>
</div>
<p class="text-sm text-muted-foreground">
Save {{ Math.round((1 - currentPrices.yearly / (currentPrices.monthly * 12)) * 100) }}%
</p>
</CardHeader>
<CardContent>
<Button @click="handleCheckout('yearly')"> Subscribe </Button>
</CardContent>
</Card>
</div>
</div>
</template>
Format prices helper
export function formatPrice(amount: number, currency: string = 'USD'): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount)
}
Currency configuration
Create a centralized currency configuration:
export interface CurrencyConfig {
code: string
symbol: string
name: string
countries: string[]
}
export const CURRENCIES: Record<string, CurrencyConfig> = {
USD: {
code: 'USD',
symbol: '$',
name: 'US Dollar',
countries: ['US', 'CA'], // US and Canada
},
EUR: {
code: 'EUR',
symbol: '€',
name: 'Euro',
countries: ['DE', 'FR', 'ES', 'IT', 'NL' /* ... other EU countries */],
},
GBP: {
code: 'GBP',
symbol: '£',
name: 'British Pound',
countries: ['GB'],
},
JPY: {
code: 'JPY',
symbol: '¥',
name: 'Japanese Yen',
countries: ['JP'],
},
}
export function getCurrencyForCountry(countryCode: string): string {
for (const [currency, config] of Object.entries(CURRENCIES)) {
if (config.countries.includes(countryCode)) {
return currency
}
}
return 'USD' // Default fallback
}
Usage:
const currency = getCurrencyForCountry(ipInfo.value.country)
Stripe price IDs by currency
Map Stripe price IDs to currencies:
export const STRIPE_PRICES = {
pro: {
monthly: {
USD: 'price_1abc123',
EUR: 'price_1abc456',
GBP: 'price_1abc789',
},
yearly: {
USD: 'price_1def123',
EUR: 'price_1def456',
GBP: 'price_1def789',
},
},
elite: {
monthly: {
USD: 'price_1ghi123',
EUR: 'price_1ghi456',
GBP: 'price_1ghi789',
},
yearly: {
USD: 'price_1jkl123',
EUR: 'price_1jkl456',
GBP: 'price_1jkl789',
},
},
}
export function getStripePriceId(
plan: 'pro' | 'elite',
interval: 'monthly' | 'yearly',
currency: string = 'USD'
): string {
return STRIPE_PRICES[plan][interval][currency] || STRIPE_PRICES[plan][interval].USD
}
Usage in checkout:
import { getStripePriceId } from '~/server/config/stripe-prices'
export default defineEventHandler(async event => {
const { plan, interval } = await readBody(event)
// Get user's country/currency from IP
const ipInfo = await getIpInfo(event)
const currency = getCurrencyForCountry(ipInfo.country)
// Get appropriate Stripe price ID
const priceId = getStripePriceId(plan, interval, currency)
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
line_items: [{ price: priceId, quantity: 1 }],
// ...
})
return { url: session.url }
})
Composable for geolocation
Create a composable for easy access to geolocation data:
export function useGeolocation() {
const ipInfo = useState<{
country: string
city?: string
isEU: boolean
} | null>('ipInfo', () => null)
const isLoading = ref(false)
const error = ref<string | null>(null)
async function fetchIpInfo() {
if (ipInfo.value) return // Already fetched
isLoading.value = true
error.value = null
try {
const data = await $fetch('/api/ip/info')
ipInfo.value = data
} catch (err) {
error.value = 'Failed to fetch location data'
logger.error('Geolocation error:', err)
// Set default fallback
ipInfo.value = { country: 'US', isEU: false }
} finally {
isLoading.value = false
}
}
const currency = computed(() => {
if (!ipInfo.value) return 'USD'
return getCurrencyForCountry(ipInfo.value.country)
})
const isEU = computed(() => ipInfo.value?.isEU ?? false)
return {
ipInfo: readonly(ipInfo),
currency,
isEU,
isLoading: readonly(isLoading),
error: readonly(error),
fetchIpInfo,
}
}
Usage:
<script setup>
const { ipInfo, currency, isEU, fetchIpInfo } = useGeolocation()
onMounted(() => {
fetchIpInfo()
})
</script>
<template>
<div>
<p>Your country: {{ ipInfo?.country }}</p>
<p>Currency: {{ currency }}</p>
<p v-if="isEU">You are in the EU</p>
</div>
</template>
Privacy considerations
GDPR compliance
If you're serving EU users, consider:
- Cookie consent - Inform users if you store location data
- Privacy policy - Explain how IP addresses are used
- Data minimization - Only collect necessary data
- No storage - Don't store IP addresses long-term
User override
Allow users to manually select their currency/location:
<script setup>
const { currency: detectedCurrency } = useGeolocation()
const userCurrency = useCookie('user-currency')
const currency = computed({
get: () => userCurrency.value || detectedCurrency.value,
set: value => {
userCurrency.value = value
},
})
</script>
<template>
<Select v-model="currency">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="USD">$ USD</SelectItem>
<SelectItem value="EUR">€ EUR</SelectItem>
<SelectItem value="GBP">£ GBP</SelectItem>
</SelectContent>
</Select>
</template>
Use cases
Localized content
Show different content based on location:
<script setup>
const { ipInfo } = useGeolocation()
const welcomeMessage = computed(() => {
if (!ipInfo.value) return 'Welcome!'
const messages = {
US: 'Welcome to our app! 🇺🇸',
DE: 'Willkommen! 🇩🇪',
FR: 'Bienvenue! 🇫🇷',
ES: '¡Bienvenido! 🇪🇸',
}
return messages[ipInfo.value.country] || 'Welcome!'
})
</script>
Regional restrictions
Block or allow access based on location:
export default defineEventHandler(async event => {
const ipInfo = await getIpInfo(event)
// Example: Block certain countries
const blockedCountries = ['XX', 'YY']
if (blockedCountries.includes(ipInfo.country)) {
throw createError({
statusCode: 403,
message: 'Service not available in your country',
})
}
})
Analytics
Track user locations for analytics:
await prisma.analytics.create({
data: {
country: ipInfo.country,
city: ipInfo.city,
event: 'page_view',
// ... other data
},
})
Caching
Cache IP lookups to reduce API calls:
const ipCache = new Map<string, any>()
export default defineEventHandler(async event => {
const clientIp = getClientIp(event)
// Check cache first
if (ipCache.has(clientIp)) {
return ipCache.get(clientIp)
}
// Fetch from IPinfo
const data = await fetchIpInfo(clientIp)
// Cache for 24 hours
ipCache.set(clientIp, data)
setTimeout(() => ipCache.delete(clientIp), 24 * 60 * 60 * 1000)
return data
})
For production, use Redis:
import { createClient } from 'redis'
const redis = createClient({ url: process.env.REDIS_URL })
export default defineEventHandler(async event => {
const clientIp = getClientIp(event)
const cacheKey = `ipinfo:${clientIp}`
// Check cache
const cached = await redis.get(cacheKey)
if (cached) {
return JSON.parse(cached)
}
// Fetch and cache
const data = await fetchIpInfo(clientIp)
await redis.setex(cacheKey, 86400, JSON.stringify(data)) // 24 hours
return data
})
Testing
Mock IP info in development
if (process.env.NODE_ENV === 'development') {
// Return test data in development
return {
country: 'US',
city: 'San Francisco',
isEU: false,
}
}
Test different countries
Add a query parameter for testing:
// ?country=DE for testing German experience
const testCountry = getQuery(event).country
if (testCountry && process.env.NODE_ENV === 'development') {
return {
country: testCountry,
isEU: isEUCountry(testCountry),
}
}