Astro Middleware: Detect Visitor Country in 15 Lines (with ApogeoAPI)
Astro 4 ships with a middleware API that runs server-side before the page renders. Combined with ApogeoAPI, you can detect the visitor's country and pass it down to every page as a typed prop — no client-side flicker.
Setup
Astro middleware lives at src/middleware.ts. Astro automatically picks it up. Make sure your project is using output: 'server' or output: 'hybrid' in astro.config.mjs (static-only sites can't run middleware).
// astro.config.mjs
import { defineConfig } from 'astro/config';
export default defineConfig({
output: 'hybrid',
adapter: /* your adapter — vercel, cloudflare, node, etc. */,
});
The middleware
// src/middleware.ts
import { defineMiddleware } from 'astro:middleware';
export const onRequest = defineMiddleware(async (ctx, next) => {
const ip =
ctx.request.headers.get('x-forwarded-for')?.split(',')[0] ??
ctx.request.headers.get('cf-connecting-ip') ??
ctx.clientAddress;
if (ip) {
try {
const res = await fetch(`https://api.apogeoapi.com/v1/api/geo/ip/${ip}`, {
headers: { 'X-API-Key': import.meta.env.APOGEOAPI_KEY },
});
if (res.ok) {
const data = await res.json();
ctx.locals.geo = {
country: data.country?.iso2,
countryName: data.country?.name,
city: data.city?.name,
currency: data.country?.currency,
currencyRate: data.country?.currencyRate,
};
}
} catch {
// fail silently; pages will fall back to defaults
}
}
return next();
});
Type the locals
Astro's ctx.locals is untyped by default. Add this to src/env.d.ts for autocomplete and type-checking:
/// <reference types="astro/client" />
declare namespace App {
interface Locals {
geo?: {
country?: string;
countryName?: string;
city?: string;
currency?: string;
currencyRate?: number;
};
}
}
Use it in any page
---
// src/pages/index.astro
const { geo } = Astro.locals;
const greeting = geo?.country === 'AR' ? '¡Hola!' : 'Hello';
---
<h1>{greeting} from {geo?.countryName ?? 'somewhere'} 👋</h1>
{geo?.currency && geo.currency !== 'USD' && (
<p>Your currency: {geo.currency} — 1 USD = {geo.currencyRate?.toFixed(2)}</p>
)}
Adding edge caching
Without caching, every page render hits ApogeoAPI. To keep API quota low, add a 1-hour cache keyed by IP. On Vercel/Cloudflare adapters this is automatic with fetch's next: { revalidate } option (Vercel) or cf: { cacheTtl } (Cloudflare):
const res = await fetch(`https://api.apogeoapi.com/v1/api/geo/ip/${ip}`, {
headers: { 'X-API-Key': import.meta.env.APOGEOAPI_KEY },
// Vercel adapter:
// @ts-expect-error — Astro fetch doesn't yet type 'next' option
next: { revalidate: 3600 },
});
For self-hosted Node, wrap in a small in-memory LRU cache (~10 lines, no library needed).
Production gotchas
- Static prerendered pages. Middleware doesn't run for routes you've explicitly marked
export const prerender = true. Either keep those pages static (no geo personalization) or remove the prerender flag. - Localhost.
ctx.clientAddresscan be::1in dev. Hardcode an IP behindimport.meta.env.DEVfor testing. - Rate limits. ApogeoAPI free tier is 1,000 req/month — with 1h cache, that covers ~5,000 unique visitors per month before you'd need to upgrade.
Free key at apogeoapi.com. Postman collection with all endpoints at /postman.
Try ApogeoAPI free
1,000 requests/month forever. 14-day full-access trial. No credit card.
Get your free API key