Geolocation
This template detects the visitor's country to pick the default display currency, and you can read it for your own features, such as localized content. 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
- Browser and server access -
/api/ipreturns the current visitor's location; server code callsgetVisitorGeodirectly. - Server-side lookups - The IPinfo token never reaches the browser.
- Nothing stored - Geolocation does not save the visitor's location or IP.
Choose a provider
| IPinfo (default) | Cloudflare | |
|---|---|---|
| Works when | Anywhere | Your site runs behind the Cloudflare proxy |
| Returns | Country, plus more fields depending on your IPinfo plan | Country only |
| Cost | One IPinfo API request per lookup | None |
| Needs | An IPinfo token | Cloudflare IP geolocation turned on |
Pick Cloudflare when your site is already behind its proxy and you only need the country. Otherwise, use IPinfo.
Quick start
IPinfo
- Get a token from IPinfo.
- Add it to
.envbefore you build, because the token is read at build time:.envNUXT_GEO_PROVIDER="ipinfo" IPINFO_TOKEN="your_ipinfo_token" - Make sure your reverse proxy overwrites
X-Forwarded-For. The visitor's IP comes from that header, so without the overwrite a visitor can send any IP they like.
Cloudflare
- Turn on Cloudflare IP geolocation for your site so proxied requests carry
CF-IPCountry. - Set the provider in
.envlocally and in your host's environment variables in production (see deployment). No token is needed:.envNUXT_GEO_PROVIDER="cloudflare" - Configure your host or firewall to accept traffic from Cloudflare's IP ranges only. Anyone who can reach your origin directly can send their own
CF-IPCountryheader.
Read the location
From the browser
/api/ip returns the current visitor's location:
ipinfo: IPinfo's response for the visitor's IP, such as{ ip, city, region, country, loc, org, postal, timezone }cloudflare:{ country }
Check for country rather than the status code. The endpoint answers 204 No Content with an empty body when the provider has no answer, and in ipinfo mode a private or loopback address (local development, or a proxy that does not forward the visitor IP) returns 200 without country.
From server code
import { getVisitorGeo } from '@@/server/services/geo-server-service'
export default defineEventHandler(async event => {
const geo = await getVisitorGeo(event)
return { country: geo?.country ?? null }
})
getVisitorGeo uses the configured provider and never throws. It returns null when the provider has no answer, and in ipinfo mode a private address returns an object without country, so check country before using the result. In cloudflare mode it does not return the visitor IP; for rate limiting by IP, see rate limiting and client IP.
Good to know
NUXT_GEO_PROVIDERis read from the server's environment at startup, so switching providers takes a restart, not a rebuild.IPINFO_TOKENis read at build time; to change it without rebuilding, setNUXT_IPINFO_TOKENin the server's environment.NUXT_GEO_PROVIDERis case-insensitive and ignores surrounding spaces. Unset or empty meansipinfo; any other value logs a warning at startup and falls back toipinfo.- Cloudflare's no-country and Tor codes (
XX,T1), and a missing or malformed header, count as an unknown country. - When an IPinfo lookup fails, for example because the token is missing or invalid, the server logs a warning and the endpoint answers
204.
Usage examples
These examples fetch the location in the browser after the page renders (server: false, lazy: true). A prerendered or cached page would otherwise serve the same location to every visitor.
Get the visitor's location
Which fields you get depends on the provider: cloudflare returns only country, and with ipinfo the fields depend on your plan. The example shows each field only when it is present.
<script setup lang="ts">
const { data: ipInfo } = useFetch('/api/ip', { server: false, lazy: true })
</script>
<template>
<div v-if="ipInfo?.country">
<p>Country: {{ ipInfo.country }}</p>
<p v-if="ipInfo.city">City: {{ ipInfo.city }}</p>
<p v-if="ipInfo.timezone">Timezone: {{ ipInfo.timezone }}</p>
</div>
</template>
Default currency by country
The pricing tables pick the currency shown first from the visitor's country. usePricingCurrency gives your own components the same currency: it detects after mount, maps the country through the prices store, and falls back to paymentsConfig.defaultCurrency. Each component that calls it requests /api/ip when it mounts. Checkout charges the price the visitor selected; they can change its currency only through a currency selector, built in or your own (see User preference override).
<script setup lang="ts">
const { displayCurrency } = usePricingCurrency()
</script>
<template>
<p>Prices in {{ displayCurrency.toUpperCase() }}</p>
</template>
Stripe checkout and currency
Charge the price the visitor saw. The pricing tables send the selected price ID and the checkout endpoint charges it, so the detected country only picks which currency is shown first. To let Stripe convert to the buyer's local currency, use Adaptive Pricing. To restrict who can buy, see Regional restrictions.
Additional use cases
Localized content
Show different content or messages based on the visitor's country:
<script setup lang="ts">
const { data: ipInfo } = useFetch('/api/ip', { server: false, lazy: true })
const welcomeMessage = computed(() => {
const messages: Record<string, string> = {
US: 'Welcome! 🇺🇸',
DE: 'Willkommen! 🇩🇪',
FR: 'Bienvenue! 🇫🇷',
}
return messages[ipInfo.value?.country ?? ''] ?? 'Welcome!'
})
</script>
<template>
<p>{{ welcomeMessage }}</p>
</template>
Regional restrictions
The detected country is a display hint. A VPN changes it, and a visitor can spoof it unless your origin accepts traffic from Cloudflare alone (cloudflare) or your proxy overwrites X-Forwarded-For (ipinfo). Use it to pick a default currency or show a "not available in your region" notice, not to decide who can pay, what they pay, or what they can access.
To restrict purchases by country, use Stripe Radar rules, which Stripe evaluates on the payment.
User preference override
Let visitors pick their currency. PricingPlans and LandingPricingTable include a currency selector backed by the prices store; pass show-currency-selector to turn it on. To build your own selector, bind it to the same store so the pricing tables on the page show the visitor's choice:
<script setup lang="ts">
import { storeToRefs } from 'pinia'
const { selectedCurrency, availableCurrencies } = storeToRefs(usePricesStore())
</script>
<template>
<Select v-model="selectedCurrency">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="currency in availableCurrencies" :key="currency" :value="currency">
{{ currency.toUpperCase() }}
</SelectItem>
</SelectContent>
</Select>
</template>
Privacy and performance
GDPR compliance: Geolocation processes the visitor's IP address. List geolocation and your provider (IPinfo or Cloudflare) in your privacy policy.
Caching: In ipinfo mode every lookup is an API request. Cache lookups if latency or your IPinfo quota matters.
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
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.