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 sessionStorage
  • PriceTag component: converts USD to local currency on mount, with FX caching
  • CountrySelector: 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