Geolocation
This template detects the visitor's location for features like dynamic pricing, localized content, and country-specific experiences. It has two providers: IPinfo, which looks up the visitor's IP address, and Cloudflare, which reads the country from the CF-IPCountry header when your site runs behind the Cloudflare proxy.
Overview
The geolocation integration provides:
- Location detection - Country with either provider; city, region and timezone with IPinfo.
- One API endpoint -
/api/ipreturns the current visitor's location. - Server-side processing - Secure API key management.
- Privacy-focused - No personal data storage required.
Configuration
Pick the provider with NUXT_GEO_PROVIDER. The server reads it at startup, so switching providers takes a restart, not a rebuild.
| Value | Source | Returns |
|---|---|---|
ipinfo (default) | IPinfo API lookup of the visitor's IP | The full IPinfo payload |
cloudflare | The CF-IPCountry request header, no API call | ip and country only |
The value is case-insensitive. An empty value means ipinfo; any other value logs a warning at startup and uses ipinfo.
IPinfo
Add your IPinfo API token to .env:
NUXT_GEO_PROVIDER="ipinfo"
IPINFO_TOKEN="your_ipinfo_token"
The visitor's IP comes from X-Forwarded-For. Your reverse proxy must overwrite that header, or a visitor can send any IP they like.
Cloudflare
NUXT_GEO_PROVIDER="cloudflare"
In the Cloudflare dashboard, turn on IP Geolocation under Network, or enable the Add visitor location headers managed transform. Either one adds CF-IPCountry to every request Cloudflare proxies to your origin. No token is needed.
Cloudflare sends XX when it has no country for the visitor and T1 for Tor. The endpoint treats both, and a missing or malformed header, as an unknown country.
CF-IPCountry header. Configure your host or firewall to accept traffic from Cloudflare's IP ranges only.API endpoint
/api/ip returns the location of the current visitor.
ipinfo:{ ip, hostname, city, region, country, loc, org, postal, timezone }, depending on your IPinfo plancloudflare:{ ip, country }
When the provider has no answer, or the IPinfo lookup fails, the endpoint answers 204 No Content with an empty body. IPinfo answers private and loopback addresses (local development, or a proxy that does not forward the visitor IP) with 200 and { ip, bogon: true }, so check for country rather than the status code.
Server code calls the same function the endpoint uses, getVisitorGeo(event) from server/services/geo-server-service.ts. It respects NUXT_GEO_PROVIDER, never throws, and returns null for an unknown location.
Usage examples
Get current user's location
City and timezone come from IPinfo only. With the cloudflare provider, ipInfo holds just ip and country.
<script setup>
const { data: ipInfo } = await useFetch('/api/ip')
</script>
<template>
<div>
<p>Country: {{ ipInfo?.country }}</p>
<p>City: {{ ipInfo?.city }}</p>
<p>Timezone: {{ ipInfo?.timezone }}</p>
</div>
</template>
Dynamic pricing by country
Detect user's country and show appropriate currency:
<script setup>
const { data: ipInfo } = await useFetch('/api/ip')
const currency = computed(() => {
const country = ipInfo.value?.country
if (country === 'GB') return 'GBP'
if (['DE', 'FR', 'ES', 'IT'].includes(country)) return 'EUR'
return 'USD'
})
const price = computed(() => {
const prices = { USD: 29, EUR: 25, GBP: 22 }
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency.value,
}).format(prices[currency.value])
})
</script>
<template>
<div>{{ price }}/month</div>
</template>
Stripe checkout with location-based pricing
Look up the visitor's country in your checkout endpoint:
import { getVisitorGeo } from '~~/server/services/geo-server-service'
export default defineEventHandler(async event => {
const { plan, interval } = await readBody(event)
// Get the visitor's location (null when unknown)
const geo = await getVisitorGeo(event)
// Map country to Stripe price ID
// You'll need to create price IDs for each currency in Stripe
const priceId = getPriceIdForCountry(plan, interval, geo?.country)
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
line_items: [{ price: priceId, quantity: 1 }],
// ...
})
return { url: session.url }
})
Additional use cases
Localized content
Show different content or messages based on user's country:
<script setup>
const { data: ipInfo } = await useFetch('/api/ip')
const welcomeMessage = computed(() => {
const messages = {
US: 'Welcome! 🇺🇸',
DE: 'Willkommen! 🇩🇪',
FR: 'Bienvenue! 🇫🇷',
}
return messages[ipInfo.value?.country] || 'Welcome!'
})
</script>
Regional restrictions
Restrict access based on location in server middleware:
import { getVisitorGeo } from '~~/server/services/geo-server-service'
export default defineEventHandler(async event => {
// Middleware runs on every request, and in ipinfo mode each lookup is an API call,
// so gate only the routes you need to restrict
if (!event.path.startsWith('/checkout')) return
const geo = await getVisitorGeo(event)
const blockedCountries = ['AA', 'BB']
if (geo?.country && blockedCountries.includes(geo.country)) {
throw createError({
statusCode: 403,
message: 'Service not available in your region',
})
}
})
The detected country is a hint that a visitor can spoof. Base pricing or access decisions on it only when your origin accepts traffic from Cloudflare alone (cloudflare) or your proxy overwrites X-Forwarded-For (ipinfo).
User preference override
Allow users to manually select their currency/location. PricingPlans and LandingPricingTable include a currency selector backed by the prices store; pass show-currency-selector to turn it on. This cookie-based example shows the pattern for your own components:
<script setup>
const { data: ipInfo } = await useFetch('/api/ip')
const userCurrency = useCookie('user-currency')
const currency = computed({
get: () => userCurrency.value || getCurrencyFromCountry(ipInfo.value?.country),
set: (value) => { userCurrency.value = value }
})
</script>
<template>
<Select v-model="currency">
<SelectItem value="USD">USD</SelectItem>
<SelectItem value="EUR">EUR</SelectItem>
<SelectItem value="GBP">GBP</SelectItem>
</Select>
</template>
Privacy and performance
GDPR compliance: IP-based geolocation is generally acceptable under GDPR as IPs are used for technical purposes. Include geolocation usage in your privacy policy.
Caching: The free tier provides unlimited API requests, but consider caching IP lookups to improve performance and reduce latency.
Error handling: When the country is unknown in either mode, the pricing tables show paymentsConfig.defaultCurrency. With show-currency-selector on, the visitor can still pick any currency you price in.
Reference
null location. It happens on network errors, a missing IPinfo token, or a request without CF-IPCountry.User feedback
Collect bug reports, feature requests and contact messages through built-in forms, validated with Zod and stored in PostgreSQL through Prisma.
Analytics
Track page views and custom events with Umami and Vercel Analytics through one composable that stays safe on the server and when a provider fails.

