IP Geolocation in Next.js 14: Middleware, App Router, and Edge Runtime
Next.js 14's Edge Middleware runs before your pages render — making it the ideal place to detect country, redirect to localized routes, or gate content. Here's a production-ready pattern using ApogeoAPI.
Why Edge Middleware?
Running geolocation in Edge Middleware means:
- Zero latency added to rendered pages — detection happens at the CDN edge
- No client-side flicker: country is known before React hydrates
- Your API key stays on the server — never exposed to the browser
- Works on Vercel, Netlify Edge, and any other Next.js edge deployment
Project Setup
npm install next@14 # already have it
# No additional packages needed — Edge Middleware uses the Web Fetch API
Add your key to .env.local:
APOGEOAPI_KEY=your_api_key_here
middleware.ts — Country Detection at the Edge
import { NextRequest, NextResponse } from 'next/server';
const GEO_API = 'https://api.apogeoapi.com/v1';
const API_KEY = process.env.APOGEOAPI_KEY!;
// Cache: simple in-memory map (edge workers have short lifetimes — per-request cache is fine)
const cache = new Map<string, { countryCode: string; currencyCode: string; expiresAt: number }>();
async function getGeoData(ip: string) {
const cached = cache.get(ip);
if (cached && cached.expiresAt > Date.now()) return cached;
const res = await fetch(`${GEO_API}/ip/${ip}?fields=countryCode,currencyCode`, {
headers: { 'X-API-Key': API_KEY },
// Edge Fetch supports next: { revalidate } for ISR-style caching
next: { revalidate: 3600 },
});
if (!res.ok) return null;
const data = await res.json();
const result = { countryCode: data.countryCode, currencyCode: data.currencyCode, expiresAt: Date.now() + 3600_000 };
cache.set(ip, result);
return result;
}
export async function middleware(req: NextRequest) {
const ip = req.ip ?? req.headers.get('x-forwarded-for')?.split(',')[0] ?? '8.8.8.8';
const geo = await getGeoData(ip);
const response = NextResponse.next();
if (geo) {
// Pass country to layouts/pages via headers
response.headers.set('x-country-code', geo.countryCode);
response.headers.set('x-currency-code', geo.currencyCode);
}
return response;
}
export const config = {
// Run on all routes except static files and API routes that don't need geo
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};
Reading Geo Data in a Server Component
In the App Router, server components can read request headers directly:
// app/pricing/page.tsx
import { headers } from 'next/headers';
const PRICE_USD = 29;
const CURRENCY_SYMBOLS: Record<string, string> = {
EUR: '€', GBP: '£', BRL: 'R$', ARS: '$', MXN: '$', CLP: '$', COP: '$',
};
async function getLocalPrice(currency: string): Promise<{ amount: number; symbol: string }> {
const res = await fetch(`https://api.apogeoapi.com/v1/currencies/${currency}/rate`, {
headers: { 'X-API-Key': process.env.APOGEOAPI_KEY! },
next: { revalidate: 14400 }, // 4 hours — matches FX refresh cadence
});
if (!res.ok) return { amount: PRICE_USD, symbol: '$' };
const { usdRate } = await res.json();
return {
amount: Math.round(PRICE_USD * usdRate),
symbol: CURRENCY_SYMBOLS[currency] ?? currency + ' ',
};
}
export default async function PricingPage() {
const headersList = headers();
const country = headersList.get('x-country-code') ?? 'US';
const currency = headersList.get('x-currency-code') ?? 'USD';
const { amount, symbol } = await getLocalPrice(currency);
return (
<section>
<h1>Pricing for {country}</h1>
<p className="price">{symbol}{amount.toLocaleString()}</p>
<p className="currency-note">Billed in {currency}</p>
</section>
);
}
Country-Based Content Gating
Restrict pages by country — GDPR compliance, regional launches, or legal requirements:
// middleware.ts — add to the middleware function
const RESTRICTED_COUNTRIES = ['CN', 'RU', 'KP'];
const RESTRICTED_PATHS = ['/dashboard', '/pricing'];
if (
RESTRICTED_PATHS.some(p => req.nextUrl.pathname.startsWith(p)) &&
geo &&
RESTRICTED_COUNTRIES.includes(geo.countryCode)
) {
return NextResponse.redirect(new URL('/region-unavailable', req.url));
}
Locale-Based Redirect
Redirect Spanish-speaking Latin American countries to the /es route:
const SPANISH_LATAM = ['AR', 'MX', 'CO', 'CL', 'PE', 'VE', 'EC', 'UY', 'PY', 'BO'];
if (geo && SPANISH_LATAM.includes(geo.countryCode) && !req.nextUrl.pathname.startsWith('/es')) {
const url = req.nextUrl.clone();
url.pathname = '/es' + req.nextUrl.pathname;
return NextResponse.redirect(url);
}
Client Components — Reading Geo from a Route Handler
For client-side use (e.g., a currency selector that initializes from the visitor's country), create a Route Handler that reads the middleware header:
// app/api/visitor-geo/route.ts
import { headers } from 'next/headers';
import { NextResponse } from 'next/server';
export async function GET() {
const h = headers();
return NextResponse.json({
countryCode: h.get('x-country-code') ?? 'US',
currencyCode: h.get('x-currency-code') ?? 'USD',
});
}
// components/CurrencySelector.tsx — client component
'use client';
import { useEffect, useState } from 'react';
export function CurrencySelector() {
const [currency, setCurrency] = useState('USD');
useEffect(() => {
fetch('/api/visitor-geo')
.then(r => r.json())
.then(d => setCurrency(d.currencyCode));
}, []);
return <select value={currency} onChange={e => setCurrency(e.target.value)}>
{['USD', 'EUR', 'GBP', 'BRL', 'ARS'].map(c => (
<option key={c} value={c}>{c}</option>
))}
</select>;
}
Performance Notes
- Cold start: Edge Middleware has near-zero cold start (~0ms) vs Node.js (~200ms). The extra
fetch()to ApogeoAPI adds ~20-50ms on miss, 0ms on cache hit. - Rate limits: Free tier is 1,000 req/month; cache the result in a cookie or localStorage so returning users don't re-trigger the lookup on every request.
- Fallback: Always default to USD/US if the API fails — never let a geolocation error break checkout.
Cookie-Based Caching (prevents repeated API calls)
// In middleware.ts — serve from cookie after first visit
const geoFromCookie = req.cookies.get('visitor-geo')?.value;
if (geoFromCookie) {
const response = NextResponse.next();
const geo = JSON.parse(geoFromCookie);
response.headers.set('x-country-code', geo.countryCode);
response.headers.set('x-currency-code', geo.currencyCode);
return response;
}
// ...fetch from API, then set cookie...
const response = NextResponse.next();
response.cookies.set('visitor-geo', JSON.stringify(geo), {
maxAge: 86400, // 24h
httpOnly: true,
sameSite: 'lax',
});
Summary
This pattern gives you country-aware Next.js apps with no client flicker, no cold-start penalty, and your API key safely on the server. The middleware runs once per request (or zero times after the cookie is set), keeping your monthly API call count low even at scale.
- Docs: api.apogeoapi.com/api/docs
- Free tier: apogeoapi.com — 1,000 req/month, 14-day full trial
Try ApogeoAPI free
1,000 requests/month forever. 14-day full-access trial. No credit card.
Get your free API key