Nuxt.jsVue.jsGeolocationTutorial

Nuxt 3 IP Geolocation: Server Middleware, useAsyncData & Country Detection

ApogeoAPI6 min read

Three approaches in Nuxt 3

  1. Server middleware — runs on every request, injects country into event context
  2. API route — a dedicated /api/geo endpoint called from the client or useAsyncData
  3. Nitro plugin — run once at startup to warm a geo cache

The cleanest pattern is the first one: server middleware sets the country on event.context, then any page or API route reads it without a second network call.

Option 1 — Server middleware (recommended)

// server/middleware/geo.ts
import { getRequestIP, getHeader, setResponseHeader } from 'h3';

const cache = new Map<string, { country: string; ts: number }>();
const TTL = 3_600_000;

export default defineEventHandler(async (event) => {
  // Get real IP (respect reverse proxy)
  const forwardedFor = getHeader(event, 'x-forwarded-for');
  const ip = forwardedFor
    ? forwardedFor.split(',')[0].trim()
    : getRequestIP(event) ?? '0.0.0.0';

  if (ip === '::1' || ip === '127.0.0.1') {
    event.context.country = 'US';
    return;
  }

  // Cache by /24 subnet
  const subnet = ip.split('.').slice(0, 3).join('.');
  const cached = cache.get(subnet);
  if (cached && Date.now() - cached.ts < TTL) {
    event.context.country = cached.country;
    return;
  }

  try {
    const data = await $fetch<{ country_code: string }>(
      `https://api.apogeoapi.com/v1/ip/${ip}`,
      { headers: { Authorization: `Bearer ${process.env.APOGEOAPI_KEY}` } }
    );
    const country = data.country_code ?? 'US';
    cache.set(subnet, { country, ts: Date.now() });
    event.context.country = country;
  } catch {
    event.context.country = 'US';
  }
});

Reading country in a page (useAsyncData)

// pages/pricing.vue
<script setup lang="ts">
const { data: geo } = await useAsyncData('geo', () =>
  $fetch('/api/current-country')
);
const country = computed(() => geo.value?.country ?? 'US');
</script>

<template>
  <div>
    <h1>Pricing for {{ country }}</h1>
    <PricingCard v-if="country === 'US'" currency="USD" :price="29" />
    <PricingCard v-else-if="country === 'GB'" currency="GBP" :price="25" />
    <PricingCard v-else currency="USD" :price="29" />
  </div>
</template>

API route that reads from event context

// server/api/current-country.get.ts
export default defineEventHandler((event) => {
  return {
    country: event.context.country ?? 'US',
    // Also available: any other fields you set in the middleware
  };
});

Richer geo data (city, currency, timezone)

Extend the middleware to fetch and cache more fields:

// server/middleware/geo.ts  (extended)
interface GeoData {
  country: string;
  city?: string;
  currencyCode?: string;
  timezone?: string;
}

// In the $fetch block:
const data = await $fetch<{
  country_code: string;
  city?: string;
  currency_code?: string;
  timezone?: string;
}>(`https://api.apogeoapi.com/v1/ip/${ip}`, {
  headers: { Authorization: `Bearer ${process.env.APOGEOAPI_KEY}` },
});

const geo: GeoData = {
  country: data.country_code ?? 'US',
  city: data.city,
  currencyCode: data.currency_code,
  timezone: data.timezone,
};
cache.set(subnet, { ...geo, ts: Date.now() });
event.context.geo = geo;

Composable for client components

// composables/useGeo.ts
export const useGeo = () => {
  const { data } = useAsyncData('geo', () => $fetch('/api/current-country'), {
    server: true,   // runs on SSR
    lazy: false,
  });
  return {
    country: computed(() => data.value?.country ?? 'US'),
    city:    computed(() => data.value?.city),
    currency: computed(() => data.value?.currencyCode),
    timezone: computed(() => data.value?.timezone),
  };
};

// Usage in any component:
// const { country, currency } = useGeo();

nuxt.config.ts — environment variable

// nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    // Server-only (not exposed to client)
    apogeoApiKey: process.env.APOGEOAPI_KEY ?? '',
  },
});

Then in server middleware, use useRuntimeConfig(event).apogeoApiKey instead of process.env.APOGEOAPI_KEY for proper Nuxt config access.

Edge deployments (Cloudflare Workers, Vercel Edge)

When deploying Nuxt to an edge runtime, the cf-ipcountry (Cloudflare) or x-vercel-ip-country (Vercel) header is already set — you don't need an external API call at all:

// server/middleware/geo.ts  (edge-first)
export default defineEventHandler((event) => {
  const country =
    getHeader(event, 'cf-ipcountry') ??          // Cloudflare
    getHeader(event, 'x-vercel-ip-country') ??   // Vercel
    'US';
  event.context.country = country;
});

Resources

Try ApogeoAPI free

1,000 requests/month forever. 14-day full-access trial. No credit card.

Get your free API key