Next.js App Router IP Geolocation: Server Components, Route Handlers & Middleware
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
matcherto skip_next/staticand images - Forgetting
dynamic = 'force-dynamic'— if your Server Component readsheaders()it opts into dynamic rendering automatically in Next.js 14; no extra config needed - Using
request.ipin production — behind a load balancer,req.ipis the LB address; usex-forwarded-forheader instead
Resources
- ApogeoAPI IP Geolocation — free tier, edge-friendly, no SDK required
- Next.js Middleware docs
- Next.js
headers()function docs
Try ApogeoAPI free
1,000 requests/month forever. 14-day full-access trial. No credit card.
Get your free API key