Redirect or set the locale by country in Next.js middleware
Look up the visitor's country once, remember it in a cookie, and send them to the right locale. The key stays on the server.
Before you start
Create a free key on the home page and store it as the environment variable IPATLAS_KEY (server-side only: no NEXT_PUBLIC_ prefix). Server-side requests share your key's quota of 50,000 per month, so the code below caches lookups and skips visitors who already have a cookie.
middleware.ts
import { NextResponse, type NextRequest } from "next/server";
// country code -> locale path; everything else gets the default
const LOCALES: Record<string, string> = { DE: "de", AT: "de", CH: "de", FR: "fr", ES: "es" };
const cache = new Map<string, { cc: string | null; exp: number }>();
async function countryOf(ip: string): Promise<string | null> {
const hit = cache.get(ip);
if (hit && hit.exp > Date.now()) return hit.cc;
let cc: string | null = null;
let ttl = 60_000; // cache failures briefly
try {
const r = await fetch(`https://api.ipatlas.dev/v1/${encodeURIComponent(ip)}`, {
headers: process.env.IPATLAS_KEY ? { "X-API-Key": process.env.IPATLAS_KEY } : {},
signal: AbortSignal.timeout(1000), // never hold up page loads
});
if (r.ok) {
cc = (await r.json()).country_code ?? null;
ttl = 6 * 3600_000;
}
} catch {}
if (cache.size >= 5000) cache.clear();
cache.set(ip, { cc, exp: Date.now() + ttl });
return cc;
}
export async function middleware(req: NextRequest) {
if (req.cookies.has("locale")) return NextResponse.next(); // decided before: no lookup
// Use the client address your platform sets. Behind a proxy this is the first x-forwarded-for entry.
const ip = req.headers.get("x-forwarded-for")?.split(",")[0].trim();
const cc = ip ? await countryOf(ip) : null;
const locale = (cc && LOCALES[cc]) || "en";
const res = locale === "en"
? NextResponse.next()
: NextResponse.redirect(new URL(`/${locale}`, req.url));
if (cc) res.cookies.set("locale", locale, { maxAge: 60 * 60 * 24 * 30, path: "/" }); // not after a failed lookup
return res;
}
export const config = { matcher: "/" }; // only the home page
How it behaves
- Fail-open. A timeout, a
429or an unknown address all end in the default locale. The page still loads. - One lookup per visitor. The
localecookie (set only after a successful lookup, so a temporary failure is retried on the next visit) stops repeat lookups, and the in-memory cache covers visitors who share an address. The cache lives per server instance or edge isolate, so treat it as a best-effort saving, not a guarantee. - The IP header is only as trustworthy as your proxy. A visitor who spoofs it only changes their own locale, but make sure your hosting platform overwrites
x-forwarded-for, otherwise someone could use your middleware to burn your quota with random addresses. - Let people switch. Country is not language. Show a language switcher and let the cookie be overwritten.
- Private and reserved addresses (local development) come back as
{"reserved": true}with no country, so you get the default locale.
Limits: without a key, 1,000 requests per day per IP address (10 req/s); with a free key, 50,000 requests per month (50 req/s). Over a limit the API returns 429. IP geolocation is approximate: country is usually right, city often is not.
Attribution
The data is DB-IP Lite, licensed CC BY 4.0. If you show or use it in a public product, link to DB-IP, for example in your footer:
<a href="https://db-ip.com">IP Geolocation by DB-IP</a>
See the API docs for fields, limits and error codes.