javascript

Готовим геотаргетинг на nginx + GeoIP2 и связываем с локализацией в Next.js

  • воскресенье, 23 марта 2025 г. в 00:00:03
https://habr.com/ru/articles/893294/

Меня зовут Александр Леуцкий, и я давно разрабатываю фронтенд, хотя нередко занимаюсь и другими задачами.

В этой статье поделюсь быстрым способом настройки геотаргетинга на nginx + GeoIP2 в связке с локализацией Next.js на примере решения реальной задачи.

Суть задачи

Вначале у продукта был один основной домен — site.com, и весь трафик шёл именно на него. Пользователи привыкли к этому адресу, а множество маркетинговых статей и публикаций уже содержали ссылки на site.com, и эти ссылки уже было невозможно изменить.

На сайте использовались локали, которые отвечали не только за язык интерфейса, но и за адаптацию контента под особенности страны. Например:

  • ru — сайт на русском + особенности работы продукта в России.

  • de — немецкий язык + адаптация под Германию.

  • en — локаль по умолчанию, с более универсальным контентом.

Со временем появился второй домен — site.ru, предназначенный для русскоязычной аудитории. Это потребовало нового механизма распределения пользователей между доменами.

Чтобы избежать сложных переходов и сохранить удобство для пользователей, договорились о следующих правилах:

  • Если пользователь заходит на site.ru, он остаётся на этом домене, и редиректы на site.com не делаем.

  • Если пользователь, например, из России попадает на site.com (или явно открывает ссылку вида site.com/ru), его автоматически перенаправляем на site.ru.

Для упрощения примеров будем рассматривать только четыре локали: ru, en, de, fr.

Переходим к практике

Решение, о котором пойдёт речь ниже - является быстрым решением, из разряда “всё горит, надо быстро и сейчас, а потом посмотрим”, но и не является плохим. Это решение легко можно использовать в системах как с простой, так и сложной архитектурой. Правда, для сложной архитектуры, где перед приложением стоит несколько слоёв из балансировщиков, прокси, а также есть иная инфраструктура, я бы выбрал что-то иное, более интересное, но не всегда есть на это время.

Начнём с пользователей. С кем же мы имеем дело?

Пользователей для этой задачи можно разделить на две группы:

  • пользователи, которые пришли в первый раз

  • пользователи, которые вернулись

Если с пользователями, которые пришли в первый раз - всё прозрачно (пользователь пришёл, определили откуда он, отобразили нужный контент), то у пользователей, которые вернулись, путь посложнее:

  • пользователь пришёл по правильной ссылке и всё хорошо, редирект не нужен

  • пользователь пришёл по ссылке, которая относится не к его домену или языку (ссылка сохранена в закладках, либо поисковик дал ему такую ссылку, либо ссылка из статьи какой-либо) - здесь уже нужно учитывать ранее сохранённые о пользователе данные и то, к какой стране относится запрашиваемый контент

Стек технологий

Выбирать долго не пришлось, связка nginx и Next.js уже была настроена в проекте и работала. Хоть проект и является частью большой инфраструктуры, но какого-то общего решения по определению местоположения не было, при этом базу GeoIP2 уже использовали в других проектах и был настроен механизм её обновления. Даже если бы пришлось всё делать с нуля, то вероятно выбор пал на этот же стек:

  • nginx - http-сервер, умеет очень многое, несложно настраивать, много модулей. В проекте отвечает за раздачу статики, сжатие, проксирование.

  • Next.js - js-фреймворк, снимающий кучу головной боли на старте и на дистанции.

  • GeoIP2 - БД для вычисления местоположения по IP-адресу. 

Схема взаимодействия

Запрос приходит на nginx. С помощью nginx + GeoIP2 определяется необходимость корректировки адреса. Если адрес нужно скорректировать, формируется новый адрес и отправляется клиенту. Если корректировать адрес не нужно, запрос передаётся дальше в Next.js. Упрощённая схема выглядит так:

Мне нравится эта схема тем, что решение о корректировке адреса делается на уровне nginx и это снимает нагрузку с Next.js.

Реальный IP-адрес пользователя

Определение местоположения по IP начинается с понимания, где этот IP взять. В nginx есть переменная $remote_addr, содержащая IP-адрес клиента, отправившего запрос. Если перед нашим nginx стоит прокси или балансировщик, то без дополнительной настройки $remote_addr будет содержать IP-адрес последнего прокси, а не реальный IP-адрес пользователя. Поэтому важно знать, что стоит перед вашим сервером, если ничего нет, то скорее всего не прийдётся что-то специально конфигурировать. В моём случае реальный IP-адрес пользователя нужно было забирать из заголовка X-Forwarded-For. У меня было кастомное решение, но сейчас я бы взял готовый модуль, например ngx_http_realip_module, конфигурация для него может выглядеть, например, так:

set_real_ip_from 0.0.0.0/0;  # Указываем любые доверенные IP (или диапазон)
real_ip_header X-Forwarded-For;  # Указываем, что нужно использовать заголовок X-Forwarded-For
real_ip_recursive on;  # Забираем последний реальный IP из списка

GeoIP2

GeoIP2 - это БД от MaxMind, она хороша тем, что регулярно обновляется, популярна, имеет модуль для nginx. В моём распоряжении была платная версия, но для этой задачи будет достаточно бесплатного варианта GeoLite2 от той же MaxMind. Скачать GeoLite2 можно после регистрации (небольшой, но посильный квест).

Важно помнить, что MaxMind регулярно обновляет БД GeoIP2/GeoLite2 и чтобы иметь свежую версию у себя на сервере, нужно настроить периодические обновления данной БД. Для этого можно использовать, например, утилиту geoipupdate.

Вообще, подходы к обновлению могут быть разные - это зависит от инфраструктуры. Например, в моём случае за свежесть базы в компании отвечают ребята из инфраструктуры (БД доступна по локальной ссылке), а в проекте база всегда была свежей из-за того, что релиз проекта делали несколько раз в неделю, т.е. всё как-то само собой получилось.

Для работы с GeoIP2 в nginx нужен модуль ngx_http_geoip2_module. В nginx этот модуль подключаем так:

load_module modules/ngx_http_geoip2_module.so;

Как и писал выше, я использовал платную версию и в моём случае файл БД назывался GeoIP2-City.mmdb.

В рамках данной статьи я ограничусь определением только страны, поэтому конфигурация выглядит так:

geoip2 /etc/nginx/GeoIP2-City.mmdb {
  $geoip2_data_country_iso_code source=$remote_addr country iso_code;
}

Теперь на каждый запрос в переменной $geoip2_data_country_iso_code будет храниться информация в виде ISO-кода о стране пользователя.

Next.js

Очень важно понимать, как работает Next.js с локализацией. Нас интересует кука NEXT_LOCALE. Одно время локализация была частью Next.js (время, когда её не было - не рассматриваем), а потом локализацию вынесли в отдельный модуль next-intl, но при этом название куки NEXT_LOCALE не изменилось и вряд ли изменится, поэтому на неё можно завязаться.

Как работает кука NEXT_LOCALE и как она связана с локалью в URL?

  • Если локаль есть в URL, то Next.js записывает эту локаль в куку NEXT_LOCALE.

  • Если локали в URL нет и нет куки NEXT_LOCALE, то Next.js определяет локаль пользователя и записывает её в NEXT_LOCALE. Запишет ли Next.js локаль в URL - это уже зависит от настроек. В моём случае настройки проекта такие, что локаль всегда должна быть в URL и если её нет, то Next.js её туда поместит.

Подробнее можно почитать здесь https://next-intl.dev/docs/routing/middleware#locale-detection

Т.к определение локали в рамках этой задачи выстраивается на основе данных о стране, то механизм определения локали на стороне Next.js не нужен и его можно отключить (или не включать), но саму концепцию с кукой NEXT_LOCALE и наличием локали в URL - оставим. При этом, Next.js всё равно работает с кукой NEXT_LOCALE, даже если механизм автоопределения выключен.

Nginx

Подошли к самой интересной части. Давайте определимся со значимостью тех или иных признаков в принятии или непринятии решения о редитекте.

  1. Домен site.ru (так и любой другой локальный для страны домен). Напомню, что с домена site.ru не перенаправляем пользователя на другой домен. Также у домена site.ru есть только одна локаль - ru. Это бизнес-условие, предполагается, что если человек перешёл на site.ru, то у него на это была либо мотивация, либо это были соответствующие статьи и т.д.

  2. Локаль в ссылке. Если она есть, то пользователь скорее всего перешёл из контента, адресованного под эту локаль (конечно, может быть иначе, но это уже другая история)

  3. Кука NEXT_LOCALE. Эта кука говорит о том, что пользователь уже был у нас и его надо перенаправить на локаль в этой куке.

  4. GeoIP2. Если нет никаких признаков локали, определяем страну пользователя и выбираем для него подходящий домен и локаль

  5. Локаль по умолчанию. Если GeoIP не помог, то отправляем пользователя на локаль по умолчанию (для site.ru - это ru, для site.com - это en)

Как вариант, можно было ещё посмотреть на заголовок Accept-Language, но это уже было бы переусложнение (по крайней мере в контексте этой задачи).

Первым шагом определим локаль в URL с помощью следующей конфигурации:

map $uri $uri_locale {
  ~^/(ru|en|de|fr)(?=$|/) $1;
}

Важно понимать, что переменная $uri_locale позволит понять, есть ли в URL поддерживаемая на данный момент локаль или нет. Например, в URL может быть локаль jp (либо старая локаль, либо пользователь вручную её добавил, либо ещё что-то), но мы её не идентифицируем как локаль. Этот момент нужно помнить и понимать, но в контексте задачи такие ситуации списываем как сильно незначительные.

Теперь определим, нужно ли выполнять редирект. Необходимость редиректа зависит от домена и локали в URL:

map "$host:$uri_locale" $need_redirect {
  ~^site.com:(en|de|fr)$ 'n';
  ~^site.ru:ru$ 'n';
  default 'y';
}

Тут всё просто: если есть локаль в урле и она соответствует своему домену, то редирект не нужен.

Переходим к сопоставлению локали со страной. Выше я выделил четыре локали, на них и будем “приземлять” страны:

map "$geoip2_data_country_iso_code" $country_locale {
  ~(RU|BY) 'ru';
  DE 'de';
  FR 'fr';
  default 'en';
}

Этот блок достаточно важный в геополитическом контексте и его конфигурация зависит от стратегии работы компании в мире. В этом месте стоит учитывать то, как компания работает с разными странами, регионами, городами.

На основе подготовленных выше данных вычислим редирект:

map "$need_redirect:$host:$uri_locale:$cookie_NEXT_LOCALE:$country_locale" $redirect_to {
  ~^y:site.ru: "https://site.ru/ru$request_uri";
  ~^y:site.com:(ru:|:ru:|::ru:) "https://site.ru/ru$request_uri";
  ~^y:site.com::(.+): "https://site.com/$cookie_NEXT_LOCALE$request_uri";
  ~^y:site.com:::(.+): "https://site.com/$country_locale$request_uri";
}

Два последних правила можно объединить, но оставил их в таком виде, чтобы было проще читать конфигурацию. Как можно заметить, переменная-флаг $need_redirect позволяет сильно снизить вариативность в этом блоке. 

Последний шаг - перенаправляем пользователя, если это нужно:

server {
  location / {
    if ($redirect_to) {
      return 302 $redirect_to;
    }

    proxy_pass http://nextjs;
  }
}

Здесь всё просто: если нужно перенаправить, то перенаправляем, иначе отдаём запрос в Next.js (nextjs в этом куске конфига - это upstream).

Итоговый частичный конфиг для nginx

Этот конфиг позволит легче сориентироваться с тем, куда можно поместить описанные выше блоки конфигурации. Полный конфиг точно будет больше, и он точно не нужен в рамках данной статьи, лишь может ввести в заблуждение, но если будут вопросы - спрашивайте.

# Загружаем модуль для geoip2
load_module modules/ngx_http_geoip2_module.so;

http {
  # Нормализуем $remote_addr [без примера конфига]

  # Настраиваем работу geoip2
  geoip2 /etc/nginx/GeoIP2-City.mmdb {
    $geoip2_data_country_iso_code source=$remote_addr country iso_code;
  }

  # Извлекаем локаль из урла
  map $uri $uri_locale {
    ~^/(ru|en|de|fr)(?=$|/) $1;
  }
  
  # Определяем необходимость редиректа
  map "$host:$uri_locale" $need_redirect {
    ~^site.com:(en|de|fr)$ 'n';
    ~^site.ru:ru$ 'n';
    default 'y';
  }

  # Сопоставляем страну и локаль
  map "$geoip2_data_country_iso_code" $country_locale {
    ~(RU|BY) 'ru';
    DE 'de';
    FR 'fr';
    default 'en';
  }

  # Вычисляем uri для редиректа
  map "$need_redirect:$host:$uri_locale:$cookie_NEXT_LOCALE:$country_locale" $redirect_to {
    ~^y:site.ru: "https://site.ru/ru$request_uri";
    ~^y:site.com:(ru:|:ru:|::ru:) "https://site.ru/ru$request_uri";
    ~^y:site.com::(.+): "https://site.com/$cookie_NEXT_LOCALE$request_uri";
    ~^y:site.com:::(.+): "https://site.com/$country_locale$request_uri";
  }

  # Upstream к nextjs
  upstream nextjs {
    server 127.0.0.1:3027;
  }

  server {
    # Основная конфигурация сервера
    
    # Обработка запросов
    location / {
      # Если нужно перенаправить, то перенаправляем, иначе пропускаем запрос в nextjs
      if ($redirect_to) {
        return 302 $redirect_to;
      }

      proxy_pass http://nextjs;
    }
  }
}

Подведём итоги

Почему мне нравится это решение:

  • Вся логика редиректов на основе GeoIP сосредоточена в одном месте и остаётся прозрачной.

  • Решение о редиректе принимается на уровне nginx, а не в Next.js → меньше кода, меньше сайд-эффектов, быстрее обработка запросов.

  • Next.js сам обновляет куку с локалью, не нужно писать свою логику.

  • Решение не требует сложной реализации и даёт команде полный контроль над редиректами, что особенно важно для стартапов, экспериментирующих с рынками.

Что мне не нравится:

  • Два источника правды: список локалей теперь хранится и в nginx, и в Next.js, что усложняет добавление новых локалей.

  • Конфигурация nginx не всем понятна, что может затруднить поддержку и онбординг.

  • Если логика смены языков усложнится, решение, скорее всего, придётся переносить в другое место.

  • Редиректы могут зависеть не только от геоданных, но и от сохранённых данных пользователя, которые nginx не видит.

  • Приходится напрямую работать с БД GeoIP2, хотя предпочтительнее было бы получать страну уже в заголовке от инфраструктуры.

Вывод: в контексте моей задачи плюсы перевешивали минусы, а само решение получилось простым, гибким и легко адаптируемым.

Спасибо, что дочитали до конца!