Next.jsApp RouterGeolocationTutorial

Next.js App Router IP Geolocation: Server Components, Route Handlers & Middleware

ApogeoAPI7 min read

App Router vs Pages Router — key differences

In the Pages Router, geolocation lived in middleware.ts + getServerSideProps. In the App Router (Next.js 13+), the pattern changes: middleware still handles detection, but you forward the result to Server Components via request headers, and to Route Handlers via the same mechanism.

Step 1 — Middleware detects country and injects header

// middleware.ts  (runs on the Edge Runtime)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export async function middleware(req: NextRequest) {
  // Option A: Vercel or Cloudflare injects the country automatically
  const country =
    req.headers.get('x-vercel-ip-country') ??       // Vercel
    req.headers.get('cf-ipcountry') ??               // Cloudflare
    await detectCountry(req);                        // self-hosted fallback

  const res = NextResponse.next();
  // Forward as a request header so Server Components can read it
  res.headers.set('x-country', country ?? 'US');
  return res;
}

async function detectCountry(req: NextRequest): Promise<string | null> {
  const ip =
    req.headers.get('x-forwarded-for')?.split(',')[0].trim() ??
    req.ip ?? '0.0.0.0';

  if (ip === '::1' || ip === '127.0.0.1') return 'US'; // localhost fallback

  const res = await fetch(`https://api.apogeoapi.com/v1/ip/${ip}`, {
    headers: { Authorization: 'Bearer ' + process.env.APOGEOAPI_KEY },
    // Edge runtime supports fetch with next.revalidate
    next: { revalidate: 3600 },
  });
  const data = await res.json();
  return data.country_code ?? null;
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};

Step 2 — Read country in a Server Component

Server Components run on the server and receive the incoming request. In Next.js 13+, use headers() from next/headers:

// app/page.tsx  (Server Component)
import { headers } from 'next/headers';

export default function HomePage() {
  const headersList = headers();
  const country = headersList.get('x-country') ?? 'US';

  return (
    <main>
      <h1>Welcome from {country}</h1>
      {country === 'US' && <UsdPricing />}
      {country === 'GB' && <GbpPricing />}
      {!['US','GB'].includes(country) && <DefaultPricing country={country} />}
    </main>
  );
}

Step 3 — Read country in a Route Handler

// app/api/pricing/route.ts
import { NextRequest, NextResponse } from 'next/server';

const PRICES: Record<string, { currency: string; amount: number }> = {
  US: { currency: 'USD', amount: 29 },
  GB: { currency: 'GBP', amount: 25 },
  DE: { currency: 'EUR', amount: 27 },
  AU: { currency: 'AUD', amount: 45 },
};

export async function GET(req: NextRequest) {
  const country = req.headers.get('x-country') ?? 'US';
  const price = PRICES[country] ?? PRICES['US'];
  return NextResponse.json({ country, ...price });
}

Step 4 — Pass country to a Client Component

Client Components can't call headers() directly. The pattern is: Server Component reads the header, then passes the value as a prop.

// app/pricing/page.tsx  (Server Component wrapper)
import { headers } from 'next/headers';
import PricingClient from './pricing-client';

export default function PricingPage() {
  const country = headers().get('x-country') ?? 'US';
  return <PricingClient country={country} />;
}

// app/pricing/pricing-client.tsx  ('use client')
'use client';
export default function PricingClient({ country }: { country: string }) {
  // Now country is available in the client component
  return <div>Pricing for {country}</div>;
}

Caching — avoid per-request API calls

Calling a geolocation API on every request adds latency. Two strategies:

  • Edge caching: use next: { revalidate: 3600 } in fetch — Next.js caches the response for 1 hour per IP
  • /24 subnet caching: group IPs by first 3 octets (1.2.3.x) — reduces unique lookups by ~250×
// Cache by /24 subnet to reduce API calls
function subnetKey(ip: string) {
  return ip.split('.').slice(0, 3).join('.');
}

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

async function getCachedCountry(ip: string): Promise<string> {
  const key = subnetKey(ip);
  const cached = cache.get(key);
  if (cached && Date.now() - cached.ts < TTL) return cached.country;

  const res = await fetch(`https://api.apogeoapi.com/v1/ip/${ip}`, {
    headers: { Authorization: 'Bearer ' + process.env.APOGEOAPI_KEY },
  });
  const { country_code } = await res.json();
  cache.set(key, { country: country_code, ts: Date.now() });
  return country_code;
}

Vercel / Cloudflare — zero-cost geo for App Router

If you deploy on Vercel, you get the country for free in the Edge Runtime:

// middleware.ts on Vercel — no API call needed
const country = req.geo?.country ?? req.headers.get('x-vercel-ip-country') ?? 'US';

On Cloudflare Pages / Workers:

const country = req.headers.get('cf-ipcountry') ?? 'US';

For self-hosted deployments (AWS, Railway, Fly.io, DigitalOcean), use ApogeoAPI — the free tier covers 1,000 requests/month and each IP result is cacheable.

Common mistakes

  • Reading headers in a Client Component — doesn't work; always pass via props from a Server Component
  • Not setting the matcher — without a matcher, middleware runs on every route including static assets; add matcher to skip _next/static and images
  • Forgetting dynamic = 'force-dynamic' — if your Server Component reads headers() it opts into dynamic rendering automatically in Next.js 14; no extra config needed
  • Using request.ip in production — behind a load balancer, req.ip is the LB address; use x-forwarded-for header instead

Resources

Try ApogeoAPI free

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

Get your free API key