Nuxt.jsVue.jsGeolocationTutorial
Nuxt 3 IP Geolocation: Server Middleware, useAsyncData & Country Detection
ApogeoAPI6 min read
Three approaches in Nuxt 3
- Server middleware — runs on every request, injects country into event context
- API route — a dedicated
/api/geoendpoint called from the client oruseAsyncData - 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
- ApogeoAPI IP Geolocation — free tier, cacheable responses
- Nuxt 3 server middleware docs
- H3 event utilities
Try ApogeoAPI free
1,000 requests/month forever. 14-day full-access trial. No credit card.
Get your free API key