AstroTutorialEdge

Astro Middleware: Detect Visitor Country in 15 Lines (with ApogeoAPI)

ApogeoAPI5 min read

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.clientAddress can be ::1 in dev. Hardcode an IP behind import.meta.env.DEV for 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