Разработка
Двуязычный сайт на 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:
- Поддомен для каждой локали (en.example.com, tr.example.com). Чёткое разделение, но управлять TLS и аналитикой дорого, а небольшой сайт визуально дробится.
- Национальный домен для каждой локали (example.com, example.com.tr). Отлично для геотаргетинга, но оправдано, только если у вас действительно бизнес именно для Турции, — иначе это дорого.
- Префикс локали в пути (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 (что он выбрал в прошлый раз). Наше правило скучное, но рабочее:
- Если в URL уже есть префикс локали, уважаем его. Ставим cookie, чтобы запомнить выбор.
- Иначе, если есть cookie с прошлого визита, перенаправляем на эту локаль.
- Иначе, если заголовок Accept-Language соответствует поддерживаемой локали, перенаправляем туда.
- Во всех остальных случаях используем локаль по умолчанию (английскую).
Мы делаем это в middleware.ts, а не в layout: так быстрее, код выполняется на edge и не возникает расхождений при гидратации. Вот упрощённая версия middleware, которое работает на neptay.com:
// 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. На двуязычном сайте в этой функции должны происходить три вещи:
- Задать title и description для текущей локали.
- Задать канонический URL, указывающий ровно на этот путь (без проблем с завершающим слешем и странностей с протоколом).
- Задать альтернативы hreflang для каждой локали, в которой существует страница, плюс x-default.
С объектом alternates.languages в Next.js это элементарно. Вот как это выглядит для отдельного маршрута:
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.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. Мы делим его на два слоя:
- Базовый граф (Organization + WebSite), который один раз внедряет корневой layout. Он сообщает, кому принадлежит сайт, где лежит логотип и на каких языках сайт опубликован.
- Схемы для отдельных маршрутов (CreativeWork, BreadcrumbList, Article, FAQPage), которые внедряет каждая страница. Это небольшие блоки JSON-LD, описывающие, чем является конкретная страница.
В обоих слоях inLanguage указывает на текущую локаль. Не пропускайте это: без inLanguage Google приходится угадывать язык по тексту страницы, а на коротких страницах это медленно и неточно.
// 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. Но для небольшого сайта типизированный словарь быстрее, легче и ловит пропущенные переводы на этапе компиляции:
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.