Автор:
    Создание:2025-09-28Последнее обновление:2025-09-28

    SEO и i18n в Next.js: Перевода недостаточно

    Когда разработчики думают об интернационализации (i18n), их первой реакцией часто является: перевести контент. Но обычно забывают, что главная цель интернационализации, сделать ваш сайт более заметным для всего мира. Если ваше многоязычное приложение Next.js не сообщает поисковым системам, как сканировать и понимать различные языковые версии, большая часть ваших усилий может остаться незамеченной.

    В этом блоге мы рассмотрим, почему i18n, это суперсила SEO, и как правильно реализовать её в Next.js с помощью next-intl, next-i18next и Intlayer.


    Почему SEO и i18n

    Добавление языков, это не только про удобство пользователя (UX). Это также мощный рычаг для органической видимости. Вот почему:

    1. Лучшая обнаруживаемость: Поисковые системы индексируют локализованные версии и ранжируют их для пользователей, ищущих на своем родном языке.
    2. Избежание дублированного контента: Правильные канонические и альтернативные теги сообщают поисковым роботам, какая страница относится к какому языку.
    3. Лучший UX: Посетители сразу попадают на правильную версию вашего сайта.
    4. Конкурентное преимущество: Немногие сайты хорошо реализуют многоязычное SEO, что даёт вам возможность выделиться.

    Лучшие практики многоязычного SEO в Next.js

    Вот чеклист, который должно реализовать каждое многоязычное приложение:

    • Устанавливайте метатеги hreflang в <head>
      Помогает Google понять, какие версии существуют для каждого языка.

    • Включайте все переведённые страницы в sitemap.xml
      Используйте схему xhtml, чтобы поисковые роботы могли легко находить альтернативные версии.

    • Исключайте приватные/локализованные маршруты в robots.txt
      Например, не позволяйте индексировать /dashboard, /fr/dashboard, /es/dashboard.

    • Используйте локализованные ссылки
      Пример: <a href="/fr/about">À propos</a> вместо ссылки на стандартную /about.

    Это простые шаги, но их пропуск может стоить вам видимости.


    Примеры реализации

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

    next-intl

    src/app/[locale]/about/layout.tsx
    import type { Metadata } from "next";
    import { locales, defaultLocale } from "@/i18n";
    import { getTranslations, unstable_setRequestLocale } from "next-intl/server";
    
    // Функция для получения локализованного пути
    function localizedPath(locale: string, path: string) {
      return locale === defaultLocale ? path : `/${locale}${path}`;
    }
    
    export async function generateMetadata({
      params,
    }: {
      params: { locale: string };
    }): Promise<Metadata> {
      const { locale } = params;
      // Получаем переводы для текущей локали и namespace "about"
      const t = await getTranslations({ locale, namespace: "about" });
    
      const url = "/about";
      const languages = Object.fromEntries(
        locales.map((l) => [l, localizedPath(l, url)])
      );
    
      return {
        title: t("title"),
        description: t("description"),
        alternates: {
          canonical: localizedPath(locale, url),
          languages: { ...languages, "x-default": url },
        },
      };
    }
    
    // ... Остальная часть кода страницы
    
    src/app/sitemap.ts
    import type { MetadataRoute } from "next";
    import { locales, defaultLocale } from "@/i18n";
    
    const origin = "https://example.com";
    
    const formatterLocalizedPath = (locale: string, path: string) =>
      locale === defaultLocale ? `${origin}${path}` : `${origin}/${locale}${path}`;
    
    export default function sitemap(): MetadataRoute.Sitemap {
      const aboutLanguages = Object.fromEntries(
        locales.map((l) => [l, formatterLocalizedPath(l, "/about")])
      );
    
      return [
        {
          url: formatterLocalizedPath(defaultLocale, "/about"),
          lastModified: new Date(),
          changeFrequency: "monthly",
          priority: 0.7,
          alternates: { languages: aboutLanguages },
        },
      ];
    }
    
    src/app/robots.ts
    import type { MetadataRoute } from "next";
    import { locales, defaultLocale } from "@/i18n";
    
    const origin = "https://example.com";
    const withAllLocales = (path: string) => [
      path,
      ...locales.filter((l) => l !== defaultLocale).map((l) => `/${l}${path}`),
    ];
    
    export default function robots(): MetadataRoute.Robots {
      const disallow = [
        ...withAllLocales("/dashboard"),
        ...withAllLocales("/admin"),
      ];
    
      return {
        // Правила для роботов
        rules: { userAgent: "*", allow: ["/"], disallow },
        host: origin,
        sitemap: `${origin}/sitemap.xml`,
      };
    }
        rules: { userAgent: "*", allow: ["/"], disallow },
        host: origin,
        sitemap: `${origin}/sitemap.xml`,
      };
    }
    

    next-i18next

    i18n.config.ts
    export const locales = ["en", "fr"] as const;
    export type Locale = (typeof locales)[number];
    export const defaultLocale: Locale = "en";
    
    /** Добавляет префикс локали к пути, если это не локаль по умолчанию */
    export function localizedPath(locale: string, path: string) {
      return locale === defaultLocale ? path : `/${locale}${path}`;
    }
    
    /** Помощник для абсолютного URL */
    const ORIGIN = "https://example.com";
    export function abs(locale: string, path: string) {
      return `${ORIGIN}${localizedPath(locale, path)}`;
    }
    
    src/app/[locale]/about/layout.tsx
    import type { Metadata } from "next";
    import { locales, defaultLocale, localizedPath } from "@/i18n.config";
    
    export async function generateMetadata({
      params,
    }: {
      params: { locale: string };
    }): Promise<Metadata> {
      const { locale } = params;
    
      // Динамически импортировать правильный JSON-файл
      const messages = (await import(`@/../public/locales/${locale}/about.json`))
        .default;
    
      const languages = Object.fromEntries(
        locales.map((l) => [l, localizedPath(l, "/about")])
      );
    
      return {
        title: messages.title,
        description: messages.description,
        alternates: {
          canonical: localizedPath(locale, "/about"),
          languages: { ...languages, "x-default": "/about" },
        },
      };
    }
    
    export default async function AboutPage() {
      return <h1>О нас</h1>;
    }
    
    src/app/sitemap.ts
    import type { MetadataRoute } from "next";
    import { locales, defaultLocale, abs } from "@/i18n.config";
    
    export default function sitemap(): MetadataRoute.Sitemap {
      const languages = Object.fromEntries(
        locales.map((l) => [l, abs(l, "/about")])
      );
      return [
        {
          url: abs(defaultLocale, "/about"),
          lastModified: new Date(),
          changeFrequency: "monthly",
          priority: 0.7,
          alternates: { languages },
        },
      ];
    }
    
    src/app/robots.ts
    import type { MetadataRoute } from "next";
    import { locales, defaultLocale, localizedPath } from "@/i18n.config";
    
    const ORIGIN = "https://example.com";
    
    const expandAllLocales = (path: string) => [
      localizedPath(defaultLocale, path),
      ...locales
        .filter((l) => l !== defaultLocale)
        .map((l) => localizedPath(l, path)),
    ];
    
    export default function robots(): MetadataRoute.Robots {
      const disallow = [
        ...expandAllLocales("/dashboard"),
        ...expandAllLocales("/admin"),
      ];
    
      return {
        rules: { userAgent: "*", allow: ["/"], disallow },
        host: ORIGIN,
        sitemap: `${ORIGIN}/sitemap.xml`,
      };
    }
    

    Intlayer

    src/app/[locale]/about/layout.tsx
    import { getIntlayer, getMultilingualUrls } from "intlayer";
    import type { Metadata } from "next";
    import type { LocalPromiseParams } from "next-intlayer";
    
    export const generateMetadata = async ({
      params,
    }: LocalPromiseParams): Promise<Metadata> => {
      const { locale } = await params;
    
      const metadata = getIntlayer("page-metadata", locale);
    
      /**
       * Генерирует объект, содержащий все URL для каждого языка.
       *
       * Пример:
       * ```ts
       *  getMultilingualUrls('/about');
       *
       *  // Возвращает
       *  // {
       *  //   en: '/about',
       *  //   fr: '/fr/about',
       *  //   es: '/es/about',
       *  // }
       * ```
       */
      const multilingualUrls = getMultilingualUrls("/about");
    
      return {
        ...metadata,
        alternates: {
          canonical: multilingualUrls[locale as keyof typeof multilingualUrls],
          languages: { ...multilingualUrls, "x-default": "/about" },
        },
      };
    };
    
    // ... Остальной код страницы
    
    src/app/sitemap.ts
    import { getMultilingualUrls } from "intlayer";
    import type { MetadataRoute } from "next";
    
    const sitemap = (): MetadataRoute.Sitemap => [
      {
        url: "https://example.com/about",
        alternates: {
          languages: { ...getMultilingualUrls("https://example.com/about") },
        },
      },
    ];
    
    src/app/robots.ts
    import { getMultilingualUrls } from "intlayer";
    import type { MetadataRoute } from "next";
    
    // Функция для получения всех многоязычных URL из массива URL
    const getAllMultilingualUrls = (urls: string[]) =>
      urls.flatMap((url) => Object.values(getMultilingualUrls(url)) as string[]);
    
    // Конфигурация robots.txt с правилами для поисковых роботов
    const robots = (): MetadataRoute.Robots => ({
      rules: {
        userAgent: "*", // Правила применяются ко всем роботам
        allow: ["/"], // Разрешенные пути
        disallow: getAllMultilingualUrls(["/dashboard"]), // Запрещенные пути (все языковые версии /dashboard)
      },
      host: "https://example.com", // Хост сайта
      sitemap: `https://example.com/sitemap.xml`, // Путь к карте сайта
    });
    
    export default robots;
    
    Intlayer предоставляет функцию getMultilingualUrls для генерации многоязычных URL-адресов для вашей карты сайта.

    Проверьте свой сайт с помощью Intlayer SEO Scanner

    Чтобы убедиться, что ваше многоязычное приложение идеально оптимизировано, мы рекомендуем использовать Intlayer SEO Scanner. Это специализированный инструмент для проверки многоязычных веб-сайтов, помогающий выявить недостающие теги hreflang, неработающие ссылки и другие проблемы SEO на всех ваших локализованных страницах.


    Заключение

    Правильная реализация i18n в Next.js, это не просто перевод текста, а обеспечение того, чтобы поисковые системы и пользователи точно знали, какую версию вашего контента показывать. Настройка hreflang, карт сайта и правил для robots, это то, что превращает переводы в реальную SEO-ценность.

    Хотя next-intl и next-i18next предоставляют надежные способы для этого, они обычно требуют много ручной настройки, чтобы поддерживать согласованность между локалями.

    Именно здесь Intlayer действительно выделяется:

    Он поставляется с встроенными помощниками, такими как getMultilingualUrls, что делает интеграцию hreflang, карты сайта и robots практически без усилий.

    Метаданные остаются централизованными, а не разбросанными по JSON-файлам или пользовательским утилитам.

    Он разработан специально для Next.js с нуля, поэтому вы тратите меньше времени на отладку конфигурации и больше времени на выпуск продукта.

    Если ваша цель, не просто переводить, а масштабировать многоязычное SEO без лишних сложностей, Intlayer предоставляет вам самое чистое и перспективное решение.

    Комментарии

    Пока нет комментариев. Будьте первым, кто поделится своими мыслями.

    Похожие сообщения

    Последние сообщения