A running list of SEO concepts, each written the same way: what it means, how it typically goes wrong, and how to fix it. Examples use a bilingual Next.js site on
example.com, but the rules are not framework-specific. New sections get added as I run into them.
canonical
A page can carry one line in its <head> that says "the official address of this page is X":
<link rel="canonical" href="https://www.example.com/posts/hello" />
rel is short for relationship: it describes how the current page relates to the address in href. canonical means "that address is my official version". When Google indexes a page and computes its ranking, it uses the canonical address, not the address the user actually typed.
The rule is simple: canonical must point at a URL that opens directly with a 200, and the easiest way to get that right is to point it at the page itself.
Here is a common way it goes wrong. Say a site serves its default language at the bare path (/posts/hello) and other languages with a prefix (/zh/posts/hello). A middleware rewrites /posts/x to /en/posts/x internally so one [lng] route can handle both, and redirects any request that literally contains /en/ back to the bare path. The article page uses Next.js's relative canonical:
export const metadata = {
alternates: { canonical: "./" },
}
On pages rendered at request time, "./" resolves against the real URL and produces /posts/hello. But if the article route is force-static, it is pre-rendered at build time with no request URL, and "./" resolves against the internal path instead. The page ends up advertising:
<link rel="canonical" href="https://www.example.com/en/posts/hello" />
Follow what Google does with that:
- It fetches
/posts/hello, gets a 200. - It reads the canonical and learns "the official address is
/en/posts/hello". - It fetches that address and gets a redirect back to step 1.
Page A says "I'm not the real one, B is", and B says "go look at A". Google cannot honour the declaration, so it picks a URL on its own and flags the page as having a bad canonical. A page that returns 200 but claims not to be the official version is worse than having no canonical at all.
The fix is to stop relying on "./" and build the canonical explicitly from the language and the path, with one helper that encodes the prefix rule:
// lib/seo.js
const langPrefix = (lng) => (lng === fallbackLanguage ? "" : `/${lng}`)
export const localizedUrl = (lng, path = "") =>
`${siteUrl}${langPrefix(lng)}${path}`
Then every page declares its own canonical through that helper. Request-time pages can do it once in the layout; static pages do it in their own generateMetadata:
export const generateMetadata = async ({ params }) => {
const { lng, id } = await params
const post = await getPost(lng, id)
return {
alternates: { canonical: localizedUrl(lng, post.url) },
}
}
Now /posts/hello says its official address is /posts/hello, and the Chinese page says /zh/posts/hello. No redirects involved.
Two more canonical mistakes that show up often:
- Host mismatch. The site redirects
example.comtowww.example.com, but the canonical is generated from ametadataBasethat still sayshttps://example.com. Every canonical then points at a redirecting host. Pick one host, redirect the other with a 301 or 308, and make the base URL match. - Cross-language canonical. Pointing the Chinese page's canonical at the English page "because it's the original" tells Google to drop the Chinese page from the index. Each language version is its own canonical; use
hreflangto relate them.
hreflang
hreflang is href language: an attribute on <link rel="alternate"> that says "the address in href is the same content in this language". A bilingual page carries one line per language, plus a fallback:
<link
rel="alternate"
hreflang="en"
href="https://www.example.com/posts/hello"
/>
<link
rel="alternate"
hreflang="zh"
href="https://www.example.com/zh/posts/hello"
/>
<link
rel="alternate"
hreflang="x-default"
href="https://www.example.com/posts/hello"
/>
Values are language codes such as en or zh, optionally with a region (zh-CN, zh-TW, en-US). x-default is the special value meaning "if none of the listed languages match the user, show this one".
Without hreflang, Google treats the English and Chinese versions as two unrelated pages. It may show the English one to a Chinese reader, or decide the two are duplicates and index only one. With hreflang, Google knows they are one piece of content in two languages: it shows the matching version per user, and the two pages share ranking signals instead of competing.
Three rules, and Google ignores the whole set if any one is broken:
- Both sides must point at each other. The English page lists the Chinese page, and the Chinese page lists the English page. A one-way declaration is discarded.
- Each page must include itself. The English page needs an
hreflang="en"line pointing at its own URL, not just lines for the other languages. - URLs must be absolute.
https://www.example.com/zh/posts/hello, never/zh/posts/hello.
With the helper above, generating the set is a small function:
// lib/seo.js
export function localizedLanguages(path, availableLanguages = languages) {
const map = Object.fromEntries(
availableLanguages.map((lng) => [lng, localizedUrl(lng, path)])
)
return { ...map, "x-default": localizedUrl(fallbackLanguage, path) }
}
export function localizedAlternates(lng, path, availableLanguages = languages) {
return {
canonical: localizedUrl(lng, path),
languages: localizedLanguages(path, availableLanguages),
}
}
and each page returns alternates: localizedAlternates(lng, path) from its metadata. Next.js renders the languages map as the <link rel="alternate" hreflang> lines. One detail worth keeping: a page might only exist in some languages, so pass the list of languages it actually has, and only those get advertised. Listing a language whose URL 404s breaks rule 1 for the whole set.
Two things to know about where hreflang can live:
- The same map can go into
sitemap.xmlas<xhtml:link rel="alternate" hreflang="...">entries. Next.js supports this directly: returnalternates: { languages }on each sitemap entry. It's a second source Google reads, and it doesn't conflict with the tags in the HTML. - A page-level
alternatesobject in Next.js replaces the layout'salternatesentirely, not merges with it. If the layout declared something else there, such as an RSS link undertypes, the page must re-declare it or it silently disappears.
To verify, fetch a page and look for the tags. Next.js writes the attribute as hrefLang in camelCase, so grep case-insensitively:
curl -s https://www.example.com/posts/hello | grep -i hreflang