Geolocation

Detect user location and show dynamic pricing with IPinfo

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:

.env
IPINFO_TOKEN="your_ipinfo_token"
Get your IPinfo token from the IPinfo dashboard. The free tier includes 50,000 requests per month.

API endpoints

Get IP information

server/api/ip/info.ts
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

server/utils/ip-helpers.ts
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

app/pages/pricing.vue
<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

app/utils/format-price.ts
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:

app/config/currencies.ts
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:

server/config/stripe-prices.ts
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:

server/api/stripe/checkout-session.post.ts
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:

app/composables/useGeolocation.ts
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:

  1. Cookie consent - Inform users if you store location data
  2. Privacy policy - Explain how IP addresses are used
  3. Data minimization - Only collect necessary data
  4. 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:

server/middleware/geo-restrict.ts
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:

server/api/ip/info.ts
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

server/api/ip/info.ts
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),
  }
}

Reference

Always provide fallback values for geolocation features. Network issues or missing API keys shouldn't break your app.