GeolocationRedirectURL RoutingTutorial

Geo-Redirect: Country-Based URL Routing and Regional Redirects

ApogeoAPI5 min read

Common geo-redirect patterns

  • Country subdomain: redirect example.comde.example.com for German users
  • Locale path: redirect example.com/example.com/de/
  • Regional domain: redirect example.comexample.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