neptay
Все статьи

Разработка

Двуязычный сайт на Next.js App Router: i18n, hreflang и структурированные данные — как надо

Практический разбор того, как мы выпускаем двуязычные (EN/TR) сайты на Next.js App Router: маршрутизация по локалям, альтернативные версии hreflang, JSON-LD для каждой локали и небольшие приёмы в middleware, благодаря которым оба языка остаются полноценными.

В этой статье

Большинство двуязычных сайтов — это одноязычные сайты с прикрученным слоем перевода. Английская версия выходит первой, только в ней работают формы, и именно её поисковики узнают первой. Переведённая версия — это JSON-файл, выпавший из памяти переводов, и меню в шапке, которое никто не тестировал.

Мы хотели построить совсем другое. neptay.com начинался на английском и турецком, а теперь работает на десяти языках из одного контентного слоя — с маршрутизацией, метаданными, картами сайта, hreflang и структурированными данными, которые учитывают локаль. Турецкая версия — не доработка задним числом, а тот же сайт, только другим голосом. Эта статья — инженерный разбор: как мы всё устроили на Next.js App Router, что поместить в middleware, как должен выглядеть hreflang, чтобы Google ему доверял, и какие небольшие структурные решения окупаются потом.

Если вы делаете небольшой двуязычный сайт — страницу студии, меню ресторана, региональный лендинг, — этот подход можно перенять почти целиком. Если вы запускаете магазин на пятидесяти языках, это фундамент: просто замените словарь на поток данных из CMS.

Как устроено пространство URL

По сути, у двуязычного сайта на Next.js есть три варианта структуры URL:

  1. Поддомен для каждой локали (en.example.com, tr.example.com). Чёткое разделение, но управлять TLS и аналитикой дорого, а небольшой сайт визуально дробится.
  2. Национальный домен для каждой локали (example.com, example.com.tr). Отлично для геотаргетинга, но оправдано, только если у вас действительно бизнес именно для Турции, — иначе это дорого.
  3. Префикс локали в пути (example.com/en/, example.com/tr/). Один домен, один деплой, один ресурс в аналитике. Именно так делаем мы, и так стоит делать большинству небольших двуязычных сайтов.

Next.js App Router аккуратно справляется с префиксами локалей, потому что маршруты задаются файловой системой. Весь сайт живёт в app/[locale]/, и каждая конечная страница получает локаль как параметр маршрута. Никакой особой главной страницы, никакого скрытого деления на «переведённые» и «непереведённые» маршруты — локаль просто ещё один сегмент.

Наша структура каталогов

Пример кода
app/
  [locale]/
    layout.tsx       ← root layout: <html lang>, nav, footer, JSON-LD, hreflang
    page.tsx         ← home
    services/page.tsx
    work/
      page.tsx       ← work index
      [slug]/page.tsx← case study
    studio/page.tsx
    contact/page.tsx
    insights/
      page.tsx
      [slug]/page.tsx
    [...rest]/page.tsx ← catch-all → notFound()
    not-found.tsx    ← localized 404
  sitemap.ts
  robots.ts

Обратите внимание, чего здесь нет: app/layout.tsx. Layout локали и есть корневой layout — он напрямую владеет элементом <html>, поэтому <html lang="…"> берётся прямо из параметра маршрута. Никаких трюков с заголовками, никаких правок на клиенте и, главное, никакого вызова headers(), который молча исключил бы все маршруты из статической генерации. Разделы вне системы локалей (наши живые демо) получают собственный корневой layout: Next.js поддерживает несколько корневых layout, и граница локали — как раз то место, где они нужны.

Middleware: определяем локаль и перенаправляем

Когда посетитель заходит на голый домен, нужно решить, куда его отправить. Сигналов три: URL (если он явно набрал /tr/), заголовок Accept-Language (чего хочет браузер) и cookie (что он выбрал в прошлый раз). Наше правило скучное, но рабочее:

  1. Если в URL уже есть префикс локали, уважаем его. Ставим cookie, чтобы запомнить выбор.
  2. Иначе, если есть cookie с прошлого визита, перенаправляем на эту локаль.
  3. Иначе, если заголовок Accept-Language соответствует поддерживаемой локали, перенаправляем туда.
  4. Во всех остальных случаях используем локаль по умолчанию (английскую).

Мы делаем это в middleware.ts, а не в layout: так быстрее, код выполняется на edge и не возникает расхождений при гидратации. Вот упрощённая версия middleware, которое работает на neptay.com:

middleware.tsTypeScript
// middleware.ts
import { NextResponse, type NextRequest } from "next/server";
import { defaultLocale, isLocale, locales } from "@/lib/i18n";

const LOCALE_COOKIE = "neptay.locale";

function pickLocaleFromAcceptLanguage(header: string | null): "en" | "tr" {
  if (!header) return defaultLocale;
  const lower = header.toLowerCase();
  if (/(^|[,;\s])tr(-|;|,|$)/.test(lower)) return "tr";
  return defaultLocale;
}

export function middleware(req: NextRequest) {
  const { pathname } = req.nextUrl;

  // Live demos live outside the locale system — they handle their own language.
  if (pathname === "/demo" || pathname.startsWith("/demo/")) {
    return NextResponse.next();
  }

  const matchedLocale = locales.find(
    (l) => pathname === `/${l}` || pathname.startsWith(`/${l}/`)
  );
  if (matchedLocale) {
    const res = NextResponse.next();
    // Remember the locale so the next bare-domain visit lands on it.
    if (req.cookies.get(LOCALE_COOKIE)?.value !== matchedLocale) {
      res.cookies.set(LOCALE_COOKIE, matchedLocale, {
        path: "/",
        maxAge: 60 * 60 * 24 * 365,
        sameSite: "lax",
      });
    }
    return res;
  }

  // Honour the user's manual choice if it was already remembered.
  const cookieLocale = req.cookies.get(LOCALE_COOKIE)?.value;
  const remembered = cookieLocale && isLocale(cookieLocale) ? cookieLocale : null;
  const detected = remembered ?? pickLocaleFromAcceptLanguage(req.headers.get("accept-language"));

  const url = req.nextUrl.clone();
  url.pathname = `/${detected}${pathname === "/" ? "" : pathname}`;
  return NextResponse.redirect(url);
}

export const config = {
  // Skip Next internals + static assets so they aren't redirected.
  matcher: [
    "/((?!_next/|api/|favicon\\.|robots\\.txt|sitemap\\.xml|.*\\.(?:png|svg|jpg|jpeg|gif|webp|ico|txt|xml)).*)",
  ],
};

Здесь окупаются две детали. Проверка Accept-Language — это настоящий разбор списка значений через запятую, а не startsWith: браузер, который отправляет "en-GB,tr;q=0.8", не должен считаться турецким только потому, что где-то встречается "tr". А cookie записывается при каждом визите с префиксом локали, а не только при ручном переключении, поэтому следующий заход на голый домен приводит на тот язык, который посетитель читал последним. Без этого переключение языка кажется сломанным: вернувшегося посетителя, который явно выбрал турецкий, снова отбрасывает к догадке по Accept-Language.

Метаданные: для каждой локали и маршрута

Любая страница в App Router может экспортировать generateMetadata. На двуязычном сайте в этой функции должны происходить три вещи:

  1. Задать title и description для текущей локали.
  2. Задать канонический URL, указывающий ровно на этот путь (без проблем с завершающим слешем и странностей с протоколом).
  3. Задать альтернативы hreflang для каждой локали, в которой существует страница, плюс x-default.

С объектом alternates.languages в Next.js это элементарно. Вот как это выглядит для отдельного маршрута:

Пример кодаTypeScript
export async function generateMetadata({ params }: { params: { locale: string } }): Promise<Metadata> {
  if (!isLocale(params.locale)) return {};
  const seo = WORK_SEO[params.locale];
  return {
    title: seo.title,
    description: seo.description,
    alternates: {
      canonical: `/${params.locale}/work`,
      languages: {
        en: "/en/work",
        tr: "/tr/work",
        "x-default": "/en/work",
      },
    },
    openGraph: { title: seo.title, description: seo.description, type: "website" },
  };
}

Две частые ошибки с hreflang:

  • Каждая альтернатива должна ссылаться обратно. Если /en/work указывает /tr/work как альтернативу, то и /tr/work должна указывать /en/work. Без обратных ссылок Google игнорирует всю группу hreflang.
  • x-default — не локаль. Это URL для пользователей, чей язык не совпадает ни с одним из ваших. Мы направляем его на /en/work, потому что английский — наш самый универсальный запасной вариант, а не потому, что он важнее.

Если страниц много, напишите небольшой хелпер, который генерирует объект alternates для любого пути. Мы прописываем всё прямо на месте: страниц у нас немного, а явную версию проще отлаживать, когда в 23:00 смотришь в Search Console.

Карта сайта и hreflang в ней

hreflang в метаданных каждого маршрута — это только полдела. Google читает hreflang и из карты сайта, и этот сигнал весомее, потому что охватывает весь сайт сразу. В App Router есть встроенное соглашение sitemap.ts; вот как выглядит наше:

app/sitemap.tsTypeScript
// app/sitemap.ts
import type { MetadataRoute } from "next";
import { locales } from "@/lib/i18n";

const SITE = "https://neptay.com";
const PATHS = ["", "services", "work", "studio", "contact", "insights"] as const;

export default function sitemap(): MetadataRoute.Sitemap {
  const lastModified = new Date();
  return locales.flatMap((locale) =>
    PATHS.map((p) => ({
      url: `${SITE}/${locale}${p ? `/${p}` : ""}`,
      lastModified,
      changeFrequency: "monthly" as const,
      priority: p === "" ? 1 : 0.7,
      alternates: {
        languages: Object.fromEntries(
          locales.map((l) => [l, `${SITE}/${l}${p ? `/${p}` : ""}`])
        ),
      },
    }))
  );
}

Обратите внимание: карта сайта содержит отдельную запись для каждой комбинации (локаль, путь), и в каждой записи перечислены все альтернативные локали. Это избыточно — намеренно. Поисковикам проще обходить явные пары, чем угадывать по шаблонам URL.

Структурированные данные: базовый граф и расширения для маршрутов

JSON-LD по Schema.org — вторая опора, на которой держится понимание многоязычного сайта со стороны Google. Мы делим его на два слоя:

  1. Базовый граф (Organization + WebSite), который один раз внедряет корневой layout. Он сообщает, кому принадлежит сайт, где лежит логотип и на каких языках сайт опубликован.
  2. Схемы для отдельных маршрутов (CreativeWork, BreadcrumbList, Article, FAQPage), которые внедряет каждая страница. Это небольшие блоки JSON-LD, описывающие, чем является конкретная страница.

В обоих слоях inLanguage указывает на текущую локаль. Не пропускайте это: без inLanguage Google приходится угадывать язык по тексту страницы, а на коротких страницах это медленно и неточно.

lib/structured-data.tsTypeScript
// lib/structured-data.ts
export const organizationLd = {
  "@type": "Organization",
  "@id": `${SITE_URL}/#organization`,
  name: "Neptay",
  url: SITE_URL,
  logo: { "@type": "ImageObject", url: `${SITE_URL}/neptay-logo.png` },
  knowsLanguage: ["en", "tr"],
  areaServed: "Worldwide",
};

export function articleLd(input: ArticleInput) {
  return {
    "@context": "https://schema.org",
    "@type": "Article",
    headline: input.headline,
    inLanguage: input.locale,            // ← critical for bilingual sites
    datePublished: input.datePublished,
    author: { "@id": `${SITE_URL}/#organization` },
    publisher: { "@id": `${SITE_URL}/#organization` },
    // …
  };
}

Паттерн словаря

Строки перевода живут в одном типизированном словаре, который экспортируется как константная карта с локалями в качестве ключей. Это немодно: сегодня обычно советуют фреймворк вроде next-intl или react-intl с форматом сообщений ICU. Но для небольшого сайта типизированный словарь быстрее, легче и ловит пропущенные переводы на этапе компиляции:

Пример кодаTypeScript
export type Locale = "en" | "tr";
export const locales: Locale[] = ["en", "tr"];

export interface Dictionary {
  locale: Locale;
  nav: { services: string; work: string; studio: string; contact: string };
  // …
}

export const dictionaries: Record<Locale, Dictionary> = {
  en: { /* … */ },
  tr: { /* … */ },
};

Если для строки нет турецкого перевода, TypeScript ругается при сборке. Если ключ добавили в английский, но не в турецкий, сборка падает. Когда языков два, это дорогого стоит: выпустить релиз можно, только если оба языка идут в ногу.

Подход перестаёт работать примерно на двадцати языках или когда тексты должны править не инженеры. Тогда переносите словарь в CMS и смиритесь с тем, что переводы будут отставать. Для двух языков и сайта студии побеждает обычный TypeScript.

Статический рендеринг, кэш на edge и почему это важно для SEO

На загрузку отдельной страницы всё описанное выше никак не влияет. Значение это имеет в масштабе. Если каждая страница рендерится статически (без запросов к базе данных во время работы и без вызовов перевода на каждый запрос), то каждая локаль каждой страницы — это HTML-документ, который можно закэшировать на edge. Время до первого байта определяет ваш CDN — обычно 30–80 мс в любой точке планеты.

Для SEO это важно по двум причинам: в Core Web Vitals у TTFB и LCP большой вес, а у краулера Google есть бюджет. Статическая страница из кэша на edge обходится краулеру в один дешёвый запрос; страница с серверным рендерингом может обойтись в десять раз дороже. Для сайта студии из пяти страниц краулинговый бюджет не проблема, а вот для многоязычного блога с двумя сотнями статей он быстро ею становится.

Что мы ещё улучшили бы

Честный список того, что ещё стоит сделать на двуязычном сайте с App Router, когда основа готова:

  • Изображения OpenGraph для каждой локали. Сейчас OG-изображение одинаковое для обеих локалей; в идеале заголовок на картинке тоже стоит переводить.
  • Автоматическое отслеживание расхождений в переводах. Шаг в CI, который сравнивает ключи словарей EN и TR и падает при несовпадении. Просто и полезно.
  • Переключатель языка, который оставляет пользователя на той же странице, если перевод есть, и аккуратно откатывается (с пояснением), если его нет.

Если вы планируете двуязычный сайт и хотите, чтобы на архитектуру взглянул кто-то ещё, — или хотите, чтобы его построили мы, — пишите на hello@neptay.com.

Neptay Media & Technology Services

Поговорим

Планируете что-то подобное?

Методы из этих статей мы применяем в клиентских проектах — разработка ПО, ИИ-автоматизация, контент, соцсети и прямые эфиры. Расскажите, что вы задумали, — мы ответим в течение 24 часов.