GeolocationRedirectURL RoutingTutorial
Geo-Redirect: Country-Based URL Routing and Regional Redirects
ApogeoAPI5 min read
Common geo-redirect patterns
- Country subdomain: redirect
example.com→de.example.comfor German users - Locale path: redirect
example.com/→example.com/de/ - Regional domain: redirect
example.com→example.de - Blocked regions: redirect to a "not available" page
Next.js Middleware (recommended)
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
// Country → locale mapping
const COUNTRY_LOCALE: Record<string, string> = {
DE: 'de', AT: 'de', CH: 'de',
FR: 'fr', BE: 'fr',
ES: 'es', MX: 'es', AR: 'es', CO: 'es',
BR: 'pt', PT: 'pt',
JP: 'ja',
CN: 'zh',
};
const DEFAULT_LOCALE = 'en';
const SUPPORTED_LOCALES = new Set(['en', 'de', 'fr', 'es', 'pt', 'ja', 'zh']);
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Skip if already on a locale path or API/static routes
if (
SUPPORTED_LOCALES.has(pathname.split('/')[1]) ||
pathname.startsWith('/api') ||
pathname.startsWith('/_next')
) {
return NextResponse.next();
}
// 1. Check for saved locale preference (cookie from language switcher)
const cookieLocale = request.cookies.get('locale')?.value;
if (cookieLocale && SUPPORTED_LOCALES.has(cookieLocale)) {
const url = request.nextUrl.clone();
url.pathname = `/${cookieLocale}${pathname}`;
return NextResponse.redirect(url, { status: 307 });
}
// 2. Detect country from IP
// On Vercel: use built-in header
const vercelCountry = request.headers.get('x-vercel-ip-country');
// On Cloudflare: use cf.country
const cfCountry = (request as any).cf?.country;
let country = vercelCountry ?? cfCountry;
// If neither: call ApogeoAPI
if (!country && process.env.APOGEO_KEY) {
const ip = request.headers.get('x-forwarded-for')?.split(',')[0] ?? '127.0.0.1';
try {
const res = await fetch(
`https://api.apogeoapi.com/v1/geo/${ip}?apikey=${process.env.APOGEO_KEY}`,
{ signal: AbortSignal.timeout(1000) }
);
const geo = await res.json();
country = geo.countryCode;
} catch {
country = 'US';
}
}
const locale = COUNTRY_LOCALE[country ?? ''] ?? DEFAULT_LOCALE;
if (locale !== DEFAULT_LOCALE) {
const url = request.nextUrl.clone();
url.pathname = `/${locale}${pathname}`;
return NextResponse.redirect(url, { status: 307 });
}
return NextResponse.next();
}
export const config = {
matcher: ['/((?!_next|api|favicon.ico|.*\..*).*)',],
};
Express.js geo redirect
import express from 'express';
import { geolocate } from './geo'; // your cached geo function
const app = express();
const REGIONAL_DOMAINS: Record<string, string> = {
DE: 'https://de.example.com',
FR: 'https://fr.example.com',
JP: 'https://jp.example.com',
};
app.use('/', async (req, res, next) => {
// Skip if already on a regional domain
if (req.hostname !== 'example.com') return next();
const ip = req.headers['x-forwarded-for']?.toString().split(',')[0] ?? req.ip;
const geo = await geolocate(ip);
const redirect = REGIONAL_DOMAINS[geo.countryCode];
if (redirect) {
// 302 = temporary (user can still use main domain)
// 307 = temporary with POST method preserved
return res.redirect(302, redirect + req.originalUrl);
}
next();
});
Nginx geo module redirect
# /etc/nginx/conf.d/geo-redirect.conf
# Requires MaxMind GeoIP2 + nginx-module-geoip2
# Or use this simpler approach with geo module (country from IP ranges)
geo $country {
default '';
include /etc/nginx/geoip/country_code.conf; # pre-built IP→country map
}
map $country $redirect_domain {
DE "de.example.com";
FR "fr.example.com";
JP "jp.example.com";
default "";
}
server {
listen 443 ssl;
server_name example.com;
if ($redirect_domain != "") {
return 302 https://$redirect_domain$request_uri;
}
# Normal handling
location / {
proxy_pass http://app;
}
}
Cloudflare Workers geo redirect
// Cloudflare Workers: zero API calls, cf.country is free
const COUNTRY_LOCALE: Record<string, string> = {
DE: 'de', FR: 'fr', ES: 'es', BR: 'pt', JP: 'ja',
};
export default {
async fetch(request: Request): Promise<Response> {
const cf = (request as any).cf;
const country = cf?.country as string ?? 'US';
const url = new URL(request.url);
// Only redirect root domain, not already-localized paths
const locale = COUNTRY_LOCALE[country];
if (locale && !url.pathname.startsWith(`/${locale}/`) && url.pathname === '/') {
url.pathname = `/${locale}/`;
return Response.redirect(url.toString(), 302);
}
return fetch(request);
},
};
Block regions and show "not available" page
// middleware.ts — block specific countries
const BLOCKED_COUNTRIES = new Set(['CN', 'RU', 'KP', 'IR', 'CU', 'SY']);
export async function middleware(request: NextRequest) {
const country = request.headers.get('x-vercel-ip-country')
?? (request as any).cf?.country
?? 'US';
if (BLOCKED_COUNTRIES.has(country)) {
const url = request.nextUrl.clone();
url.pathname = '/region-not-available';
return NextResponse.rewrite(url); // rewrite (not redirect) to hide the block
}
return NextResponse.next();
}
Avoid redirect loops
Common mistake: the redirect target page also goes through middleware and triggers another redirect. Prevent this:
// In middleware.ts — skip if already on correct locale path
const localePrefix = pathname.split('/')[1];
if (SUPPORTED_LOCALES.has(localePrefix)) {
return NextResponse.next(); // already localized, skip
}
// Also skip: API, static files, images, fonts
if (
pathname.startsWith('/api/') ||
pathname.startsWith('/_next/') ||
pathname.includes('.') // has file extension
) {
return NextResponse.next();
}
Performance: use 307 for first visit, then remember in cookie
// After redirecting, set a cookie so subsequent visits don't need geo lookup
const response = NextResponse.redirect(localizedUrl, { status: 307 });
response.cookies.set('locale', locale, {
maxAge: 365 * 24 * 3600, // 1 year
sameSite: 'lax',
secure: true,
});
return response;
API docs: api.apogeoapi.com/api/docs. Free tier: app.apogeoapi.com/register.
Try ApogeoAPI free
1,000 requests/month forever. 14-day full-access trial. No credit card.
Get your free API key