Практическое SEO в Next.js: реальные уроки с madrimov.uz
Когда я перестраивал madrimov.uz на Next.js (app router), я считал SEO чем-то, что «добавляется в конце». На практике оказалось иначе: routing, layout, middleware — каждое архитектурное решение напрямую влияет на индексацию. В этой статье показываю, что я шаг за шагом сделал на своём сайте, и реальные ошибки, допущенные по пути — всё проверено в production.
Canonical и hreflang: не в layout, а на каждой странице
Самая дорогая ошибка случилась именно здесь. Сайт работает на трёх языках (uz/ru/en), и я, «чтобы всё лежало в одном месте», прописал canonical и hreflang в общем app/[lang]/layout.tsx. Next.js наследует metadata из layout на все вложенные страницы — в результате каждая статья блога стала canonicalize на URL главной страницы. Это классическая ситуация, когда страницы выпадают из индекса с пометкой «Duplicate, Google chose different canonical» в Search Console; к счастью, я поймал ошибку до деплоя, проверяя canonical в отрендеренном HTML.
Правильное решение — формировать canonical и hreflang на каждой странице отдельно, внутри generateMetadata:
// app/[lang]/blog/[slug]/page.tsx
const BASE = "https://madrimov.uz";
export async function generateMetadata({ params }: Props) {
const { lang, slug } = await params;
const path = `/blog/${slug}`;
return {
alternates: {
canonical: `${BASE}/${lang}${path}`,
languages: {
uz: `${BASE}/uz${path}`,
ru: `${BASE}/ru${path}`,
en: `${BASE}/en${path}`,
"x-default": `${BASE}/uz${path}`,
},
},
};
}Правило простое: alternates никогда не должен лежать в layout. В layout оставляйте только то, что не зависит от страницы — например, metadataBase и title template.
sitemap.ts и robots.ts — как код
В app router sitemap — это не статический файл, а route handler. Для меня это очень удобно, потому что статьи приходят из Notion и список постоянно меняется:
// app/sitemap.ts
import type { MetadataRoute } from "next";
import { getAllPosts } from "@/lib/notion";
const BASE = "https://madrimov.uz";
const LANGS = ["uz", "ru", "en"];
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await getAllPosts();
const postEntries = posts.flatMap((post) =>
LANGS.map((lang) => ({
url: `${BASE}/${lang}/blog/${post.slug}`,
lastModified: post.updatedAt,
})),
);
return [
...LANGS.map((lang) => ({ url: `${BASE}/${lang}`, priority: 1 })),
...postEntries,
];
}robots устроен так же:
// app/robots.ts
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
return {
rules: { userAgent: "*", allow: "/", disallow: ["/api/"] },
sitemap: "https://madrimov.uz/sitemap.xml",
};
}Когда выходит новая статья, sitemap обновляется автоматически — руками делать ничего не нужно.
html lang: ошибка с захардкоженным "en"
Вторая реальная ошибка: в root layout был жёстко прописан <html lang="en">. Узбекские и русские страницы тоже сигнализировали Google «это английский контент» — это противоречит hreflang и вводит в заблуждение screen reader'ы. Проблема в том, что root layout, находясь вне сегмента [lang], не может получить язык через params. Решение — передавать язык через request header в middleware:
// middleware.ts
import { NextResponse, type NextRequest } from "next/server";
export function middleware(req: NextRequest) {
const lang = req.nextUrl.pathname.split("/")[1] || "uz";
const headers = new Headers(req.headers);
headers.set("x-locale", lang);
return NextResponse.next({ request: { headers } });
}// app/layout.tsx
import { headers } from "next/headers";
export default async function RootLayout({ children }) {
const lang = (await headers()).get("x-locale") ?? "uz";
return (
<html lang={lang}>
<body>{children}</body>
</html>
);
}Один header — и теперь каждая страница корректно объявляет свой язык.
JSON-LD: связываем страницы через @id
Structured data — официальный язык, на котором вы говорите Google «чей это сайт и что это за страница». У меня на главной лежат Person + WebSite, на каждой статье — BlogPosting + BreadcrumbList. Ключевой момент — связать их через @id: автор статьи должен быть не новым отдельным объектом, а ссылкой на Person с главной страницы. Тогда Google читает весь сайт как единый граф:
// На странице статьи
const jsonLd = {
"@context": "https://schema.org",
"@graph": [
{
"@type": "BlogPosting",
"@id": `${url}#article`,
headline: post.title,
datePublished: post.publishDate,
inLanguage: lang,
author: { "@id": "https://madrimov.uz/#person" },
},
{
"@type": "BreadcrumbList",
itemListElement: [
{ "@type": "ListItem", position: 1, name: "Blog", item: `${BASE}/${lang}/blog` },
{ "@type": "ListItem", position: 2, name: post.title },
],
},
],
};
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>;Объекту Person на главной странице задаётся "@id": "https://madrimov.uz/#person" — все статьи ссылаются именно на этот идентификатор.
OG-картинка для каждой статьи — и ловушка в prod
Чтобы при шаринге ссылки в Telegram или LinkedIn появлялась красивая карточка, я сделал динамическую картинку для каждой статьи через next/og. Стандартный способ из документации — загружать шрифт через fetch(new URL("./font.ttf", import.meta.url)). В dev работает. А в prod-сборке падает с ERR_INVALID_URL: в standalone-сборке import.meta.url превращается в путь к файлу внутри бандла, и fetch не может его открыть. Рабочее решение — обычный readFile:
// app/[lang]/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from "next/og";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
export const size = { width: 1200, height: 630 };
export default async function OgImage({ params }: Props) {
const { slug } = await params;
const post = await getPost(slug);
// fetch(new URL("./font.ttf", import.meta.url)) — в prod даёт ERR_INVALID_URL!
const font = await readFile(
join(process.cwd(), "assets/fonts/Inter-SemiBold.ttf"),
);
return new ImageResponse(
<div style={{ fontFamily: "Inter", fontSize: 64 }}>{post.title}</div>,
{ ...size, fonts: [{ name: "Inter", data: font, weight: 600 }] },
);
}Файл шрифта кладите не в public, а в отдельную папку, и убедитесь, что он попадает в деплой — в standalone-режиме может понадобиться настройка outputFileTracingIncludes.
Soft-404: неизвестный slug не должен возвращать 200
Поскольку статьи приходят из внешней CMS, у меня стоит dynamicParams = true. У этого есть неожиданный побочный эффект: URL вроде /blog/ne-sushestvuet тоже пытается отрендерить страницу и возвращает статус 200. Google помечает такие страницы как soft-404 — пустые страницы попадают в индекс и портят качество сайта. Решение — сразу вызывать notFound() в generateMetadata, если пост не найден:
import { notFound } from "next/navigation";
export async function generateMetadata({ params }: Props) {
const { slug, lang } = await params;
const post = await getPost(slug, lang);
if (!post) notFound(); // настоящий статус 404 + noindex
return { title: post.title };
}notFound() даёт настоящий статус-код 404 и мету noindex — Google больше вообще не учитывает эти URL.
RSS-фид и его объявление
RSS не даёт прямого буста в ранжировании, но работает для агрегаторов, Telegram-ботов и быстрого обнаружения контента. В app router это обычный route handler: app/rss.xml/route.ts возвращает XML-строку. Важно объявить наличие фида через alternates.types. Здесь есть тонкость: если страница задаёт свой объект alternates, он полностью заменяет тот, что в layout. Поэтому я собрал всё в один helper и использую его на каждой странице:
// lib/seo.ts — helper, используемый на каждой странице
const BASE = "https://madrimov.uz";
export function buildAlternates(lang: string, path: string) {
return {
canonical: `${BASE}/${lang}${path}`,
languages: Object.fromEntries(
["uz", "ru", "en"].map((l) => [l, `${BASE}/${l}${path}`]),
),
types: { "application/rss+xml": `${BASE}/rss.xml` },
};
}Тогда <link rel="alternate" type="application/rss+xml"> появляется на каждой странице, и обнаружение фида автоматизируется.
Вывод: SEO — это архитектурное решение
Через несколько недель ошибки duplicate в Search Console исчезли, все три языковые версии попали в индекс по отдельности, предупреждения soft-404 прекратились. Короткий чеклист:
- Canonical + hreflang — только на уровне страницы, внутри
generateMetadata - sitemap.ts и robots.ts — route handler'ы, собираемые из CMS автоматически
<html lang>— динамически через middleware header- JSON-LD — единый граф, связанный через
@id - OG-картинка —
next/og+ шрифт черезreadFile(не fetch!) - Неизвестный slug — настоящий 404 через
notFound() - RSS — route handler + объявление в
alternates.types
Главный урок: SEO — это не плагин, который ставится один раз, а архитектурное решение, принимаемое ещё на этапе проектирования routing'а. Исправлять потом всегда дороже.