A running list of SEO concepts, each written the same way: what it means, how it typically goes wrong, how to fix it, and why the fix is the right one. Examples use a bilingual Next.js App Router site on
example.com(English at the bare path, Chinese under/zh), but most of 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
One host
example.com and www.example.com are two different hosts to a search engine. A site must pick one, redirect the other to it, and generate every URL it emits from the chosen one.
How it goes wrong: the hosting platform is configured with www.example.com as the project domain, the bare domain merely points at the platform, and the platform helpfully redirects it. That implicit redirect is usually a 307, a temporary one. A 307 tells Google "this is a detour, keep the original URL", so both hosts stay in the index and split the ranking signals. Meanwhile metadataBase in the layout still says https://example.com, so every canonical, og:url, RSS link and sitemap entry points at the host that redirects.
The fix has two halves and both are needed:
- Add the bare domain to the project explicitly and configure it as a 301 or 308 redirect to the
wwwhost. Google treats 301 and 308 the same: permanent, consolidate on the target. - Set the base URL in code to the chosen host, in one place, and derive everything from it:
// data/siteMetadata.js
const siteUrl = "https://www.example.com"
export default { siteUrl, socialBanner: `${siteUrl}/og.png` /* ... */ }
Why one place: canonical, og:url, twitter:image, the RSS <link>, sitemap.xml, robots.txt and JSON-LD all embed the host. If they are hand-written they drift; if they all read siteUrl they cannot.
Verify with curl, following redirects and printing each hop:
curl -sSIL https://example.com/posts/hello | grep -iE '^(HTTP/2|location)'
# HTTP/2 301 location: https://www.example.com/posts/hello
# HTTP/2 200
robots.txt and sitemap.xml
robots.txt tells crawlers what not to fetch and where the sitemap is. sitemap.xml lists every URL you want indexed, with a last-modified date and, on a multilingual site, the hreflang set per URL. Neither is optional: without a sitemap a small site is indexed only as fast as Google stumbles across links.
How it goes wrong in the App Router: Next.js has file conventions for both, app/robots.js and app/sitemap.js. They only work at the root of app/. Put them under app/[lng]/ and they mount at /en/robots.txt and /zh/robots.txt, while /robots.txt falls through to the [lng] dynamic segment with lng = "robots.txt" and renders an HTML page with a 200. Google reads that as "no robots.txt" and "no sitemap".
The fix is to move both files to app/. Then make the sitemap actually correct for a multilingual site:
// app/sitemap.js
export default async function sitemap() {
const posts = await getAllPosts()
return [
...languages.flatMap((lng) =>
STATIC_ROUTES.map((route) => ({
url: localizedUrl(lng, route),
lastModified: today,
alternates: { languages: localizedLanguages(route) },
}))
),
...posts.map((post) => ({
url: localizedUrl(post.language, post.url),
lastModified: post.lastmod || post.date,
alternates: {
languages: localizedLanguages(post.url, languagesOf(post)),
},
})),
]
}
Two details that matter:
- Every language gets its own URL. A sitemap that emits
/posts/hellofor both the English and the Chinese file lists the same URL twice and never mentions/zh/posts/hello. alternates.languagesbecomes<xhtml:link rel="alternate" hreflang>entries. That is the sitemap form of hreflang and Google reads it alongside the HTML tags.
Why not disallow anything in robots.txt for private pages: see the noindex section below. The robots.txt here only needs Allow: / plus the sitemap URL.
Verify:
curl -sI https://www.example.com/robots.txt | grep -i content-type # text/plain
curl -s https://www.example.com/sitemap.xml | grep -c '<url>' # number of pages
Real 404s
A URL that does not exist must answer with HTTP status 404. Google calls a page that returns 200 with "not found" content a soft 404: it gets crawled, sometimes indexed, wastes crawl budget, and anyone linking to a typo creates a junk page on your domain. Real 404s are simply dropped.
How it goes wrong, three different ways on the same site:
- The catch-all renders a page. A route like
app/[lng]/(404)/[...not-found]/page.jscatches every unmatched path, but it is a normal page component, so/abc,/fr/postsand/zh/abcall render a nice "Page not found" with status 200. - The dynamic segment accepts anything.
[lng]matchesrobots.txt,foo.txt,xx. Paths containing a dot skip the locale middleware (so real files inpublic/can be served), and when no such file exists the first segment lands in[lng]and the home page renders with<html lang="foo.txt">. - Unknown post slugs render an empty page. The post page did
if (!post) return <div>no post</div>, status 200.
The fixes, one per cause:
- The catch-all page throws instead of rendering, and the UI moves to
not-found.jsnext to the layout:
// app/[lng]/(404)/[...not-found]/page.js
import { notFound } from "next/navigation"
export default function CatchAll() {
notFound()
}
Throwing notFound() is what makes Next.js set the 404 status; rendering a component never does.
- The root layouts reject any
lngthat is not a configured language:
export function assertSupportedLanguage(lng) {
if (!siteMetadata.languages.includes(lng)) notFound()
}
- For routes whose params are a known finite set,
generateStaticParamsplusdynamicParams = false:
export const dynamic = "force-static"
export const dynamicParams = false
export async function generateStaticParams() {
return posts.map((post) => ({ lng: post.language, id: post.slug }))
}
Any param combination not in that list gets a routing-level 404 before any component runs.
Three things about dynamicParams that are easy to get wrong:
- It applies to the whole route, not just the segment that exports it. Next.js checks every segment of a route and uses
falseif any of them says so. Setting it on the[lng]layout would also 404 every/gallery/[imageId]that was not pre-generated, because that child route has nogenerateStaticParams. Put it on the leaf page whose params you can enumerate. generateStaticParamsmust emit params that match real URLs. A build that generatesid: "en/hello"while the URL is/posts/hellopre-renders one empty page per post that nobody ever visits and serves the real URLs on demand. WithdynamicParams = falsethat same bug would 404 every real article. Print the generated params once and compare them with the URLs in your sitemap.notFound()after the shell has streamed gives a 200. With aloading.jsin the tree, the layout is flushed first with status 200 and the page'snotFound()arrives later as a streamed fallback; Next.js injects<meta name="robots" content="noindex">as a consolation. Throw from a layout, fromgenerateMetadata, or usedynamicParams = falsewhen you need the status code itself.
Verify a set of paths in one go:
for p in /abc /fr/posts /zh/abc /foo.txt /posts/no-such-post /gallery/no-such-id; do
printf '%-24s ' "$p"; curl -s -o /dev/null -w '%{http_code}\n' "https://www.example.com$p"
done
Meta description
<meta name="description"> is the snippet Google prefers to show under the title in results. Without it Google writes its own from whatever text it finds, which is usually the first paragraph or the navigation.
How it goes wrong: no article has a description in its frontmatter, so every article page ships with no description tag at all, and the list page carries scaffold copy such as "This is blog list description". The scaffold copy is worse than nothing, because it gets shown.
The fix is unglamorous: write one sentence per page, 120–160 characters in English, 50–80 characters in Chinese, saying what the reader will get. Store it with the content:
---
title: A Deep Dive into React's startTransition
date: 2026-01-01
tags: ["react"]
description: "What marking a setState as a transition does: interruptible background rendering, a suppressed Suspense fallback, and what isPending times."
---
and thread frontmatter.description into metadata.description, openGraph.description and twitter.description.
Two rules for the text:
- Describe the content, do not restate the title. "Everything you need to know about startTransition" is the title again. Name the technique, the trap, the comparison.
- Never describe content that does not exist. A chapter stub whose body is a number gets a description built from its title only. A description that promises "wiring MDX with GFM and code highlighting" for an empty page is a lie Google will eventually hold against the site.
If the site can override copy from a database or CMS, know which source wins before editing the file. Editing a JSON file that a database row shadows changes nothing on the live site.
noindex for private pages
Admin dashboards, sign-in, sign-up and password-reset pages have no search value and should not appear in results. The tool is the robots meta tag:
export const metadata = {
robots: { index: false, follow: false },
}
How it goes wrong: the admin page does redirect("/admin/login") for anonymous users, which looks safe. But with a loading.js in the tree the shell streams first with status 200, and the redirect arrives afterwards as a client instruction. Googlebot receives a 200 page titled "Admin Dashboard" with robots: index, follow. Auth pages, meanwhile, still carry the create-next-app scaffold metadata (title: "Next.js", description: "Generated by Next.js") and no robots directive at all.
The fix is one robots block in the layout of each private route group, which covers every page under it. Give the auth layout a real title and description while you are there.
Why not Disallow: /admin in robots.txt: disallow means "do not fetch". Google then cannot see the noindex tag, and a page that is already indexed stays indexed indefinitely. noindex requires the page to be crawlable. Use noindex first; add a disallow only after the pages are out of the index, and only if crawl budget matters.
follow: false is optional. noindex, follow is the more common form and lets link equity flow through; use nofollow where the outgoing links are genuinely private, such as an admin area.
Open Graph
Open Graph (og:*) is the set of <meta> tags that Facebook, WeChat, Twitter, Slack and Discord read to build the preview card when a link is shared. It does not affect ranking; it affects whether a shared link shows a picture and a title.
How it goes wrong:
og:imagepoints at a web page. A value likehttps://github.com/usernameis an HTML page, not an image. The platform fetches it, gets HTML, and shows no picture.og:imagemust be a direct URL to a PNG or JPEG, ideally 1200×630.og:localeis hard-coded.locale: "zh_CN"in the layout means every English page claims to be Chinese. Map it from the language:en → en_US,zh → zh_CN.- A page-level
openGraphwipes the layout's. Next.js merges metadata shallowly per top-level key. A list page that setsopenGraph: { title, description }replaces the whole object from the layout, soog:url,og:image,og:site_nameandog:localedisappear on that page. The same is true ofalternates, which is how a page can silently lose the RSS link.
The fix is a shared base object that every page-level openGraph spreads first:
// lib/seo.js
export const siteOpenGraph = {
title: siteMetadata.title,
description: siteMetadata.description,
siteName: siteMetadata.title,
images: [siteMetadata.socialBanner],
type: "website",
}
export const ogLocale = (lng) => ({ en: "en_US", zh: "zh_CN" })[lng] ?? "en_US"
// any page
openGraph: {
...siteOpenGraph,
title: t("title"),
url: localizedUrl(lng, "/posts"),
locale: ogLocale(lng),
}
For the image itself, a static 1200×630 PNG in public/ referenced by absolute URL is the robust option: it is served as a file, passes through any locale middleware untouched, and involves no redirect. Next.js's opengraph-image.js convention can generate per-page images, but under a [lng] segment its URL is /en/.../opengraph-image, which then redirects, and not every crawler follows redirects for images.
Internal links
Every <a href> on the site should point at a URL that returns 200 directly. A link to a URL that redirects costs the user an extra round trip on every click and costs the crawler an extra fetch per page.
How it goes wrong: with English at the bare path and a middleware that redirects /en/... to /..., every component builds links as /${lng}/posts. For English users that is /en/posts → 308 → /posts, on every click, on every crawl. Two side effects hid in the same code: /${lng}/${post.url} produced /en//posts/hello because post.url already starts with a slash, and a gallery grid linked to /gallery/<id> with no language at all, dropping Chinese readers onto the English page.
The fix is one helper that encodes the prefix rule for site-relative paths, used everywhere a link is built:
export const localizedPath = (lng, path = "") =>
`${langPrefix(lng)}${path}` || "/"
localizedPath("en", "/posts") // "/posts"
localizedPath("zh", "/posts") // "/zh/posts"
localizedPath("en", "") // "/"
Anything that compared the current path against /en/... (active-state highlighting, the language switcher) has to be updated in the same change. Grep for the old pattern before shipping; the only legitimate hits left should be the middleware itself.
Verification
Every fix above was checked with curl, not by reading code. The commands worth keeping:
# Redirect chain, one line per hop
curl -sSIL https://example.com/ | grep -iE '^(HTTP/2|location)'
# Everything SEO-relevant in a page head
curl -s https://www.example.com/posts/hello \
| grep -oiE '<title>[^<]*|<link rel="(canonical|alternate)"[^>]*>|<meta (name|property)="(description|robots|og:[a-z_]+|twitter:[a-z]+)"[^>]*>'
# Status codes for a batch of paths
for p in /abc /posts/no-such-post; do printf '%s ' "$p"; curl -s -o /dev/null -w '%{http_code}\n' "https://www.example.com$p"; done
Note that Next.js renders the attribute as hrefLang in camelCase, so grep for it case-insensitively.