Practical SEO in Next.js: Real Lessons from madrimov.uz
When I rebuilt madrimov.uz on Next.js (app router), I treated SEO as something you "add at the end". In practice it turned out differently: routing, layout, middleware — every architectural decision directly affects indexing. In this article I show what I did on my own site step by step, and the real mistakes made along the way — all of it battle-tested in production.
Canonical and hreflang: per page, not in the layout
My most expensive mistake happened right here. The site runs in three languages (uz/ru/en), and to "keep everything in one place" I put canonical and hreflang into the shared app/[lang]/layout.tsx. Next.js inherits metadata from the layout down to every nested page — as a result, every blog article started canonicalizing to the homepage URL. This is the classic case where pages drop out of the index with "Duplicate, Google chose different canonical" in Search Console; luckily I caught it before deploying, while checking the canonical tags in the rendered HTML.
The correct solution is to build canonical and hreflang on each page individually, inside 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}`,
},
},
};
}The rule is simple: alternates must never live in a layout. Keep only page-independent things there — for example, metadataBase and the title template.
sitemap.ts and robots.ts — as code
In the app router, the sitemap is not a static file but a route handler. That is very convenient for me, because articles come from Notion and the list changes all the time:
// 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 works exactly the same way:
// 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",
};
}When a new article is published, the sitemap updates automatically — nothing to do by hand.
html lang: the hardcoded "en" mistake
The second real mistake: the root layout had <html lang="en"> hardcoded. Uzbek and Russian pages were also signaling "this is English content" to Google — contradicting hreflang and misleading screen readers. The problem is that a root layout sitting outside the [lang] segment cannot get the language from params. The fix is to pass the language through a request header in 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>
);
}One header — and now every page declares its language correctly.
JSON-LD: linking pages via @id
Structured data is the official language for telling Google "whose site this is and what this page is". On my homepage I keep Person + WebSite, on every article — BlogPosting + BreadcrumbList. The key point is linking them via @id: the article's author should not be a new standalone object but a reference to the Person from the homepage. Then Google reads the whole site as a single graph:
// On the article page
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) }}
/>;The Person object on the homepage gets "@id": "https://madrimov.uz/#person" — every article points to exactly this identifier.
A per-article OG image — and the prod trap
To get a nice card when a link is shared on Telegram or LinkedIn, I generate a dynamic image for each article with next/og. The standard approach from the docs — loading the font with fetch(new URL("./font.ttf", import.meta.url)) — works in dev. But the prod build crashes with ERR_INVALID_URL: in a standalone build, import.meta.url becomes a file path inside the bundle, and fetch cannot open it. The working solution is a plain 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)) — throws ERR_INVALID_URL in prod!
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 }] },
);
}Put the font file in a dedicated folder rather than public, and make sure it lands in the deploy — in standalone mode you may need the outputFileTracingIncludes setting.
Soft-404: an unknown slug must not return 200
Since articles come from an external CMS, I have dynamicParams = true. This has an unexpected side effect: a URL like /blog/does-not-exist also tries to render the page and returns a 200 status. Google flags such pages as soft-404 — empty pages land in the index and hurt site quality. The fix is to call notFound() right away in generateMetadata when the post is missing:
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(); // a real 404 status + noindex
return { title: post.title };
}notFound() produces a real 404 status code and a noindex meta — Google stops counting these URLs entirely.
The RSS feed and where to declare it
RSS gives no direct ranking boost, but it works for aggregators, Telegram bots, and fast content discovery. In the app router it is a plain route handler: app/rss.xml/route.ts returns an XML string. The important part is declaring the feed via alternates.types. There is a subtlety here: if a page defines its own alternates object, it fully replaces the one from the layout. So I gathered everything into a single helper and use it on every page:
// lib/seo.ts — a helper used on every page
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` },
};
}With that, <link rel="alternate" type="application/rss+xml"> appears on every page and feed discovery becomes automatic.
Conclusion: SEO is an architecture decision
A few weeks later, the duplicate errors in Search Console were gone, all three language versions were indexed separately, and the soft-404 warnings stopped. A short checklist:
- Canonical + hreflang — page level only, inside
generateMetadata - sitemap.ts and robots.ts — route handlers built automatically from the CMS
<html lang>— dynamic via a middleware header- JSON-LD — a single graph linked via
@id - OG image —
next/og+ font viareadFile(not fetch!) - Unknown slug — a real 404 via
notFound() - RSS — a route handler + declaration in
alternates.types
The biggest lesson: SEO is not a plugin you install once — it is an architecture decision made back when you design your routing. Fixing it later always costs more.