Redirect visitors by country without hurting SEO or locking out search engines

Automatically redirecting by IP looks simple, and it is one of the easiest ways to make your other country sites invisible in search. Here is what Google recommends, a pattern that works for people and crawlers, and code for it.

On this page What Google says

You run example.com, example.com/de/ and example.com/fr/, or one shop with prices in several currencies. Sending each visitor to the right version based on their IP sounds like a quick win. Done naively, it causes three problems:

  1. Search engines only see one version. Crawlers come from a few locations. Redirect them by IP and your other country pages may never get indexed.
  2. Travellers and VPN users get stuck. A German customer on holiday in Spain can't reach the German site.
  3. Shared links break. Someone in France opens a link to the US page, gets redirected, and sees something different from what was shared.

This guide shows a pattern that avoids all three.

What Google says

Google's guide to managing multi-regional and multilingual sites is direct about it:

  • "Avoid automatically redirecting" users from one language version to another, and "don't redirect based on what you think the user's language may be."
  • "Don't use IP analysis to adapt your content."
  • "the Googlebot crawler usually originates from the USA."

The last point explains the first two. If your site redirects every US IP to /us/, Googlebot is mostly redirected to /us/ too. Google's recommended alternatives are separate URLs per locale, hreflang annotations between them, and letting the user choose.

The pattern: suggest, don't force

The approach that works for people and crawlers alike:

  1. Every locale has its own stable URL (/de/, /fr/ or de.example.com), linked with hreflang.
  2. No automatic redirect. Every URL serves its own content to everyone, crawlers included.
  3. Use the IP country to suggest. If a visitor from Germany lands on the US page, show a banner: "Looks like you're in Germany. Go to the German store?"
  4. Remember the choice in a cookie, so the banner doesn't come back and an explicit choice always wins over the IP.

That keeps the convenience of geolocation and none of the downsides. The hreflang markup looks like this:

html
<link rel="alternate" hreflang="en-us" href="https://example.com/us/">
<link rel="alternate" hreflang="de-de" href="https://example.com/de/">
<link rel="alternate" hreflang="fr-fr" href="https://example.com/fr/">
<link rel="alternate" hreflang="x-default" href="https://example.com/">

Server-side: Express

Detect the country on the server, so the API key stays private, and pass a suggestion to the template:

js
import express from 'express';
import cookieParser from 'cookie-parser';

const SITES = { DE: '/de/', AT: '/de/', CH: '/de/', FR: '/fr/', BE: '/fr/', US: '/us/' };
const app = express();
app.set('trust proxy', 1); // match your proxy setup exactly
app.use(cookieParser());

async function countryOf(ip) {
  try {
    const res = await fetch(`https://fast.ipapi.gr/api/lookup/${encodeURIComponent(ip)}?key=${process.env.IPAPI_KEY}`,
      { signal: AbortSignal.timeout(1500) });
    if (!res.ok) return null;
    const code = (await res.json()).country_code;
    return code && code !== 'No Data' ? code : null; // missing fields are "No Data"
  } catch {
    return null; // never block the page on geolocation
  }
}

app.use(async (req, res, next) => {
  res.locals.suggestion = null;
  if (req.cookies.site_choice) return next();             // explicit choice wins
  const country = await countryOf(req.ip);
  const target = country && SITES[country];
  const current = Object.values(SITES).find((p) => req.path.startsWith(p));
  if (target && target !== current) res.locals.suggestion = { country, target };
  next();
});

app.post('/site-choice', express.urlencoded({ extended: false }), (req, res) => {
  const choice = Object.values(SITES).includes(req.body.site) ? req.body.site : '/';
  res.cookie('site_choice', choice, { maxAge: 365 * 24 * 3600 * 1000, sameSite: 'lax', httpOnly: true });
  res.redirect(303, choice);
});

The template renders the banner only when suggestion is set. Crawlers get the page as is, with no redirect and no special-casing.

Cache the country per visitor (in the session, or a short-lived signed cookie) so you don't make a lookup on every page view.

Server-side: PHP

php
<?php
$sites = ['DE' => '/de/', 'AT' => '/de/', 'FR' => '/fr/', 'US' => '/us/'];
$suggest = null;

if (empty($_COOKIE['site_choice'])) {
    $country = $_SESSION['country'] ?? null;
    if ($country === null) {
        $ctx = stream_context_create(['http' => ['timeout' => 1.5, 'ignore_errors' => true]]);
        $url = 'https://fast.ipapi.gr/api/lookup/' . rawurlencode($_SERVER['REMOTE_ADDR'])
             . '?key=' . urlencode(getenv('IPAPI_KEY'));
        $body = @file_get_contents($url, false, $ctx);
        $data = $body ? json_decode($body, true) : null;
        $country = $data['country_code'] ?? '';
        if ($country === 'No Data') $country = '';
        $_SESSION['country'] = $country;
    }
    if (isset($sites[$country]) && strpos($_SERVER['REQUEST_URI'], $sites[$country]) !== 0) {
        $suggest = $sites[$country];
    }
}
// In the template: if ($suggest) show the banner with a link to $suggest

$_SESSION needs session_start() earlier in the request. Behind a proxy, make sure REMOTE_ADDR is the client address. Most frameworks have a trusted-proxy setting for this.

When a hard redirect is justified

Sometimes you must change what's served by location: legal restrictions, licensing, a product you can't sell in a country. Then:

  • Exempt verified search crawlers, so the indexed content matches what most users see. Verify them by IP, because a user-agent check would let anyone bypass the restriction by pretending to be Googlebot. See how to verify Googlebot and Bingbot.
  • Use a 302, never a 301. A permanent redirect gets cached by browsers and tells search engines the content has moved for good.
  • Show a page explaining why, rather than silently sending people elsewhere.

With the IPAPI.GR API the crawler exception is one field. On Pro, risk.is_crawler is true for addresses in search engines' published ranges or with forward-confirmed reverse DNS:

js
const geo = await (await fetch(`https://fast.ipapi.gr/api/lookup/${encodeURIComponent(req.ip)}?key=${process.env.IPAPI_KEY}`)).json();
const isVerifiedCrawler = geo.risk?.is_crawler === true;
if (!isVerifiedCrawler && BLOCKED_COUNTRIES.has(geo.country_code)) {
  return res.status(451).render('unavailable-in-region');
}

Status 451 Unavailable For Legal Reasons exists for exactly this case, and it's clearer than a redirect.

Nginx-only setups

If you'd rather decide at the edge, the same rules apply: no redirects for the main content, a suggestion at most, and a crawler exception for hard restrictions. Edge platforms often pass the visitor country in a header (Cloudflare's CF-IPCountry, for example), which saves a lookup. Only trust such a header when the origin accepts traffic from that CDN alone.

Testing your setup

Before shipping, confirm that crawlers and people from other countries see what you expect.

1. No redirect for the main URLs. Fetch each locale URL and check the status:

bash
for path in / /de/ /fr/ /us/; do
  printf '%-6s ' "$path"; curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' "https://example.com$path"
done

Every line should be 200 with no redirect URL. Run it from a server in another country too, or through a VPN, because that's what your visitors and crawlers do.

2. hreflang is reciprocal. Each locale page must link to all the others and itself. Missing return links are the most common hreflang mistake.

3. Google sees each version. In Search Console, use URL Inspection on /de/ and /fr/ and look at the rendered page. If it shows US content, something is still redirecting or swapping content by IP.

4. The banner respects the choice. Pick a site, reload, and check that the banner is gone and the cookie is set.

A Python (Django) version

python
import requests
from django.conf import settings

SITES = {"DE": "/de/", "AT": "/de/", "FR": "/fr/", "US": "/us/"}

class CountrySuggestionMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        request.site_suggestion = None
        if not request.COOKIES.get("site_choice"):
            country = request.session.get("country")
            if country is None:
                try:
                    r = requests.get(
                        f"https://fast.ipapi.gr/api/lookup/{request.META['REMOTE_ADDR']}",
                        params={"key": settings.IPAPI_KEY}, timeout=1.5)
                    country = r.json().get("country_code", "") if r.ok else ""
                    if country == "No Data":
                        country = ""
                except requests.RequestException:
                    country = ""
                request.session["country"] = country
            target = SITES.get(country)
            if target and not request.path.startswith(target):
                request.site_suggestion = target
        return self.get_response(request)

Templates read request.site_suggestion to render the banner. Behind a proxy, set REMOTE_ADDR correctly in your server or middleware first.

FAQ

Is a JavaScript redirect any better?

No. Googlebot renders JavaScript, so a script redirect based on location has the same effect as a server redirect. It's also slower for users.

What about the Accept-Language header?

It's a better signal for language than the IP: a French speaker in Germany wants French. Google warns against redirecting on it too. Use it the same way, to suggest. Use the IP country for region (currency, shipping, legal) and Accept-Language for language.

Will a suggestion banner hurt Core Web Vitals?

Not if it doesn't push content down after load. Reserve its space, or render it server-side as the Express example does.

More on the lookup itself: get a visitor's country from their IP in Node.js, Python, PHP and Go.