VueVue 3TutorialGeolocationNuxt
Country Detection in Vue 3: IP Geolocation with Pinia and Composables
ApogeoAPI5 min read
Vue 3's Composition API and Pinia make it easy to build a geo detection system that works across components — and with Nuxt 3, you can run the lookup server-side so there's no client flicker. Here's a complete implementation.
Project Setup
npm install pinia # already included in Nuxt 3
Add your key to .env:
VITE_APOGEOAPI_KEY=your_api_key_here
# For Nuxt: NUXT_PUBLIC_APOGEOAPI_KEY or use server-side runtimeConfig
useGeo Composable
// composables/useGeo.ts
import { ref, computed } from 'vue';
interface GeoData {
countryCode: string;
countryName: string;
currencyCode: string;
currencySymbol: string;
}
const CURRENCY_SYMBOLS: Record<string, string> = {
EUR: '€', GBP: '£', BRL: 'R$', ARS: '$', MXN: '$',
CLP: '$', COP: '$', JPY: '¥', CNY: '¥', INR: '₹',
};
const DEFAULT_GEO: GeoData = {
countryCode: 'US',
countryName: 'United States',
currencyCode: 'USD',
currencySymbol: '$',
};
export function useGeo() {
const geo = ref<GeoData | null>(null);
const loading = ref(false);
const error = ref<string | null>(null);
const countryCode = computed(() => geo.value?.countryCode ?? DEFAULT_GEO.countryCode);
const currencyCode = computed(() => geo.value?.currencyCode ?? DEFAULT_GEO.currencyCode);
const currencySymbol = computed(() => geo.value?.currencySymbol ?? DEFAULT_GEO.currencySymbol);
async function detect() {
// Check sessionStorage first — avoids repeated API calls on navigation
const cached = sessionStorage.getItem('visitor-geo');
if (cached) {
geo.value = JSON.parse(cached);
return;
}
loading.value = true;
error.value = null;
try {
const res = await fetch('https://api.apogeoapi.com/v1/ip/self', {
headers: { 'X-API-Key': import.meta.env.VITE_APOGEOAPI_KEY },
});
if (!res.ok) throw new Error('API error ' + res.status);
const data = await res.json();
const result: GeoData = {
countryCode: data.countryCode ?? 'US',
countryName: data.countryName ?? 'United States',
currencyCode: data.currencyCode ?? 'USD',
currencySymbol: CURRENCY_SYMBOLS[data.currencyCode] ?? data.currencyCode + ' ',
};
geo.value = result;
sessionStorage.setItem('visitor-geo', JSON.stringify(result));
} catch (e) {
error.value = (e as Error).message;
geo.value = DEFAULT_GEO; // graceful fallback
} finally {
loading.value = false;
}
}
return { geo, loading, error, countryCode, currencyCode, currencySymbol, detect };
}
Pinia Store (shared across all components)
// stores/geo.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useGeoStore = defineStore('geo', () => {
const countryCode = ref('US');
const currencyCode = ref('USD');
const currencySymbol = ref('$');
const detected = ref(false);
const loading = ref(false);
const CURRENCY_SYMBOLS: Record<string, string> = {
EUR: '€', GBP: '£', BRL: 'R$', ARS: '$', MXN: '$', CLP: '$',
};
async function init() {
if (detected.value) return;
const cached = typeof window !== 'undefined' ? sessionStorage.getItem('visitor-geo') : null;
if (cached) {
const data = JSON.parse(cached);
countryCode.value = data.countryCode;
currencyCode.value = data.currencyCode;
currencySymbol.value = data.currencySymbol;
detected.value = true;
return;
}
loading.value = true;
try {
const res = await fetch('https://api.apogeoapi.com/v1/ip/self', {
headers: { 'X-API-Key': import.meta.env.VITE_APOGEOAPI_KEY },
});
const data = await res.json();
countryCode.value = data.countryCode ?? 'US';
currencyCode.value = data.currencyCode ?? 'USD';
currencySymbol.value = CURRENCY_SYMBOLS[data.currencyCode] ?? data.currencyCode + ' ';
detected.value = true;
sessionStorage.setItem('visitor-geo', JSON.stringify({
countryCode: countryCode.value,
currencyCode: currencyCode.value,
currencySymbol: currencySymbol.value,
}));
} catch {
// Keep defaults on error
} finally {
loading.value = false;
}
}
function localizePrice(usdAmount: number, rate: number): string {
return currencySymbol.value + Math.round(usdAmount * rate).toLocaleString();
}
return { countryCode, currencyCode, currencySymbol, detected, loading, init, localizePrice };
});
App.vue — Initialize on Mount
<!-- App.vue -->
<script setup lang="ts">
import { onMounted } from 'vue';
import { useGeoStore } from '@/stores/geo';
const geoStore = useGeoStore();
onMounted(() => geoStore.init());
</script>
PriceTag Component
<!-- components/PriceTag.vue -->
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useGeoStore } from '@/stores/geo';
const props = defineProps<{ usdPrice: number }>();
const geoStore = useGeoStore();
const exchangeRate = ref(1);
const localPrice = computed(() =>
geoStore.currencySymbol + Math.round(props.usdPrice * exchangeRate.value).toLocaleString()
);
onMounted(async () => {
const currency = geoStore.currencyCode;
if (currency === 'USD') return;
const cached = sessionStorage.getItem(`fx:${currency}`);
if (cached) { exchangeRate.value = parseFloat(cached); return; }
const res = await fetch(`https://api.apogeoapi.com/v1/currencies/${currency}/rate`, {
headers: { 'X-API-Key': import.meta.env.VITE_APOGEOAPI_KEY },
});
if (res.ok) {
const data = await res.json();
exchangeRate.value = data.usdRate;
sessionStorage.setItem(`fx:${currency}`, String(data.usdRate));
}
});
</script>
<template>
<span class="price">
<span v-if="geoStore.loading">...</span>
<template v-else>
{{ localPrice }}
<small class="text-gray-400">({{ geoStore.currencyCode }})</small>
</template>
</span>
</template>
CountrySelector Component
<!-- components/CountrySelector.vue -->
<script setup lang="ts">
import { ref, onMounted } from 'vue';
interface Country { iso2: string; name: string; flag: string; }
const countries = ref<Country[]>([]);
const selected = ref('');
const loading = ref(true);
onMounted(async () => {
const cached = sessionStorage.getItem('apogeo-countries');
if (cached) {
countries.value = JSON.parse(cached);
} else {
const res = await fetch('https://api.apogeoapi.com/v1/countries?fields=iso2,name,flag', {
headers: { 'X-API-Key': import.meta.env.VITE_APOGEOAPI_KEY },
});
const data = await res.json();
countries.value = data.data ?? [];
sessionStorage.setItem('apogeo-countries', JSON.stringify(countries.value));
}
// Pre-select visitor's country
const geo = sessionStorage.getItem('visitor-geo');
if (geo) selected.value = JSON.parse(geo).countryCode;
loading.value = false;
});
</script>
<template>
<div class="country-selector">
<select v-if="!loading" v-model="selected">
<option v-for="c in countries" :key="c.iso2" :value="c.iso2">
{{ c.flag }} {{ c.name }}
</option>
</select>
<span v-else>Loading countries...</span>
</div>
</template>
Nuxt 3 — Server-Side Detection (no flicker)
In Nuxt 3, run the geo lookup in a server route so country data arrives with the first HTML response:
// server/api/visitor-geo.get.ts
export default defineEventHandler(async (event) => {
const ip = getRequestHeader(event, 'cf-connecting-ip')
?? getRequestHeader(event, 'x-forwarded-for')?.split(',')[0]
?? event.node.req.socket?.remoteAddress
?? '8.8.8.8';
const apiKey = useRuntimeConfig().apogeoApiKey;
const res = await $fetch(`https://api.apogeoapi.com/v1/ip/${ip.trim()}`, {
headers: { 'X-API-Key': apiKey },
});
return { countryCode: res.countryCode, currencyCode: res.currencyCode };
});
// nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
apogeoApiKey: process.env.NUXT_APOGEO_API_KEY,
public: {},
},
});
// composables/useServerGeo.ts (Nuxt 3)
export const useServerGeo = () => useFetch('/api/visitor-geo', { key: 'visitor-geo' });
Summary
useGeoStore(Pinia): single source of truth, initializes once, cached in sessionStoragePriceTagcomponent: converts USD to local currency on mount, with FX cachingCountrySelector: pre-selects visitor's country automatically- Nuxt 3 server route: zero flicker, no client-side API key exposure
Get your free API key at app.apogeoapi.com/register — 1,000 req/month free, 14-day full trial on signup. API reference: api.apogeoapi.com/api/docs.
Try ApogeoAPI free
1,000 requests/month forever. 14-day full-access trial. No credit card.
Get your free API key