Next.js'da amaliy SEO: madrimov.uz tajribasidan real saboqlar
madrimov.uz saytini Next.js (app router) ustida qayta qurganimda SEO'ni "oxirida qo'shib qo'yiladigan narsa" deb hisoblagandim. Amalda boshqacha chiqdi: routing, layout, middleware — har bir arxitektura qarori indeksatsiyaga bevosita ta'sir qilar ekan. Bu maqolada o'z saytimda bosqichma-bosqich qilgan ishlarimni va yo'lda qilingan real xatolarni ko'rsataman — hammasi production'da sinalgan.
Canonical va hreflang: layout'ga emas, har sahifaga
Eng qimmat xatom shu yerda bo'ldi. Sayt uch tilda ishlaydi (uz/ru/en) va men canonical bilan hreflang'ni "bir joyda tursin" deb umumiy app/[lang]/layout.tsx fayliga yozib qo'ygandim. Next.js metadata'ni layout'dan barcha ichki sahifalarga meros qilib beradi — natijada blogdagi har bir maqola bosh sahifaning URL'iga canonicalize bo'lib qoldi. Bu — Search Console'da "Duplicate, Google chose different canonical" belgisi bilan sahifalar indeksdan chiqib ketadigan klassik holat; baxtga qarshi xatoni deploy'dan oldin, HTML'dagi canonical'larni tekshirayotganda ushladim.
To'g'ri yechim — canonical va hreflang har sahifaning o'zida, generateMetadata ichida shakllanishi:
// 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}`,
},
},
};
}Qoida oddiy: alternates hech qachon layout'da turmasin. Layout'da faqat sahifaga bog'liq bo'lmagan narsalar qolsin — masalan, metadataBase va title template.
sitemap.ts va robots.ts — kod sifatida
App router'da sitemap statik fayl emas, route handler. Bu men uchun juda qulay, chunki maqolalar Notion'dan keladi va ro'yxat doim o'zgarib turadi:
// 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 ham xuddi shunday tarzda:
// 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",
};
}Yangi maqola chiqqanida sitemap avtomatik yangilanadi — qo'lda hech narsa qilish shart emas.
html lang: qattiq yozilgan "en" xatosi
Ikkinchi real xato: root layout'da <html lang="en"> qattiq yozib qo'yilgan edi. O'zbekcha va ruscha sahifalar ham Google'ga "bu inglizcha kontent" deb signal berardi — bu hreflang'ga zid va screen reader'lar uchun ham noto'g'ri. Muammo shundaki, root layout [lang] segmentidan tashqarida tursa, params orqali tilni ololmaydi. Yechim — middleware'da tilni request header sifatida uzatish:
// 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>
);
}Bitta header — va endi har sahifa o'z tilini to'g'ri e'lon qiladi.
JSON-LD: sahifalarni @id bilan bog'lash
Structured data — Google'ga "bu sayt kimniki va bu sahifa nima" deyishning rasmiy tili. Menda bosh sahifada Person + WebSite, har maqolada BlogPosting + BreadcrumbList turadi. Eng muhim nuqta — ularni @id orqali bir-biriga bog'lash: maqola muallifi alohida yangi obyekt emas, bosh sahifadagi Personga ishora bo'lishi kerak. Shunda Google butun saytni yagona graf sifatida o'qiydi:
// Maqola sahifasida
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) }}
/>;Bosh sahifadagi Person obyektiga "@id": "https://madrimov.uz/#person" beriladi — barcha maqolalar aynan shu identifikatorga ishora qiladi.
Har maqolaga OG rasm — va prod'dagi tuzoq
Telegram yoki LinkedIn'da havola ulashilganda chiroyli kartochka chiqishi uchun har maqolaga next/og bilan dinamik rasm yasadim. Hujjatlardagi standart usul — fontni fetch(new URL("./font.ttf", import.meta.url)) bilan yuklash. Dev'da ishlaydi. Prod build'da esa ERR_INVALID_URL bilan yiqiladi: standalone build'da import.meta.url bundle ichidagi fayl yo'liga aylanadi va fetch uni ocholmaydi. Ishlaydigan yechim — oddiy 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'da 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 }] },
);
}Font faylini public ichiga emas, alohida papkaga qo'ying va u deploy'ga kirishiga ishonch hosil qiling — standalone rejimda outputFileTracingIncludes sozlamasi kerak bo'lishi mumkin.
Soft-404: noma'lum slug 200 qaytarmasin
Maqolalar tashqi CMS'dan kelgani uchun menda dynamicParams = true. Bunda kutilmagan yon effekt bor: /blog/mavjud-emas kabi URL ham sahifani render qilishga urinadi va 200 status qaytaradi. Google bunday sahifalarni soft-404 deb belgilaydi — bo'sh sahifalar indeksga tushib, sayt sifatiga putur yetkazadi. Yechim — generateMetadata ichida post topilmasa darhol notFound() chaqirish:
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(); // haqiqiy 404 status + noindex
return { title: post.title };
}notFound() haqiqiy 404 status kodi va noindex metasini beradi — Google bu URL'larni endi umuman hisobga olmaydi.
RSS feed va uni e'lon qilish
RSS bevosita reyting bermaydi, lekin agregatorlar, Telegram botlar va kontentni tez topish uchun ishlaydi. App router'da bu oddiy route handler: app/rss.xml/route.ts XML string qaytaradi. Muhimi — feed mavjudligini alternates.types orqali e'lon qilish. Bu yerda bitta nozik jihat bor: sahifa o'z alternates obyektini bersa, u layout'dagisini to'liq almashtiradi. Shuning uchun men hammasini bitta helper'ga yig'dim va uni har sahifada ishlataman:
// lib/seo.ts — har sahifada ishlatiladigan 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` },
};
}Shunda <link rel="alternate" type="application/rss+xml"> har sahifada chiqadi va feed'ni topish avtomatlashadi.
Xulosa: SEO — arxitektura qarori
Bir necha haftadan keyin Search Console'dagi duplicate xatolar yo'qoldi, uchala til versiyasi alohida-alohida indeksga tushdi, soft-404 ogohlantirishlari to'xtadi. Qisqa checklist:
- Canonical + hreflang — faqat sahifa darajasida,
generateMetadataichida - sitemap.ts va robots.ts — CMS'dan avtomatik quriladigan route handler
<html lang>— middleware header orqali dinamik- JSON-LD —
@idbilan bog'langan yagona graf - OG rasm —
next/og+ fontreadFilebilan (fetch emas!) - Noma'lum slug —
notFound()orqali haqiqiy 404 - RSS — route handler +
alternates.typesda e'lon
Eng katta saboq: SEO — bir marta o'rnatib qo'yiladigan plagin emas, routing'ni loyihalash bosqichidayoq qabul qilinadigan arxitektura qarori. Keyin tuzatish har doim qimmatroqqa tushadi.