feat(seo): language + canonical signals (per-locale <html lang>, x-default, fallback noindex)#110
Merged
Merged
Conversation
…allbacks
Restructure app/ into (en) and (zh) route groups, each with its own root
layout that owns <html lang>. Server-rendered HTML now correctly carries
lang="en" for /* and lang="zh" for /zh/*. Resolves the audit's P0 item
where Chinese pages were indexed as English because all routes inherited
<html lang="en"> from the single root layout.
Changes:
- app/(en)/layout.tsx and app/(zh)/layout.tsx as parallel root layouts.
Both own <html>, <body>, theme script, ThemeProvider, LanguageProvider
(with initialLanguage), header/footer.
- Old app/layout.tsx and app/zh/layout.tsx removed. Their metadata moved
into the new root layouts.
- All EN routes moved into app/(en)/. All ZH routes moved into
app/(zh)/zh/. sitemap.ts, robots.ts, globals.css stay at app/ root.
- LanguageProvider now receives initialLanguage from the layout so the
SSR pass renders the correct language without waiting for the client
effect to re-sync from URL.
Metadata fixes on every detail and index page:
- alternates.canonical set on every page with stable content.
- alternates.languages now includes x-default everywhere.
- Detail pages where essay/periodic/series lang doesn't match the URL
locale (the cross-language fallback / meta-refresh case) return
robots: { index: false, follow: true } so they don't dilute the index.
- ZH about page description: replace "杨飞同" with "Feitong Yang".
Tests:
- layout.test.ts now covers both root layouts and also asserts
metadataBase, x-default, and template/default structure.
- home-page.test.tsx import path updated for the move.
164 tests pass. Build OK under output: 'export'. Dev server smoke-tested:
EN routes serve <html lang="en">, ZH routes serve <html lang="zh"> in
the initial server HTML.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
PR 3 of the SEO plan (
plan/docs/seo/order-of-operations.md). Lands the route-group restructure that was deferred from PR #109, plus the smaller per-page metadata fixes. Together these close the remaining P0/P1 items on language + canonical signals.The big change: route groups
Restructured
app/into two parallel root layouts via Next 14 route groups:The previous single root layout always rendered
<html lang="en">, so Chinese routes were being indexed as English — the audit's P0 #4. Now every ZH route ships<html lang="zh">in the server-rendered HTML, before any client JS runs.LanguageProvidertakesinitialLanguagefrom the layout so the React state is already correct on the SSR pass.Per-page metadata fixes
On every essay/periodic/series detail page (EN + ZH × 3 = 6 files) and every index page (EN + ZH × essays/periodics/series/about = 8 files):
alternates.canonicalexplicitly set everywhere.alternates.languages['x-default']added on every page. Points at the EN URL by convention.noindex, follow. When/essays/foo-zh/is hit (zh content at an en URL) — whether the page meta-refreshes to a translation or shows the "Content in Chinese" fallback — the metadata declaresrobots: { index: false }. Stops Google from indexing dozens of near-duplicate "wrong language" landing pages. Symmetric on the zh side.(zh)/zh/about/page.tsxdescription with "Feitong Yang" (consistent with the prior PR's branding decision).Tests
__tests__/layout.test.tsnow covers both root layouts viadescribe.each. Asserts default + template title structure, metadataBase, x-default hreflang.__tests__/home-page.test.tsximport path updated for the move.Verified in built output
/→<html lang="en">/zh/→<html lang="zh" data-language="zh">/essays/decision-game/→<html lang="en">with canonical + en/zh/x-default hreflang/zh/essays/decision-game-zh/→<html lang="zh">with canonical + en/zh/x-default/essays/decision-game-zh/(wrong-language URL with redirect) →<meta name="robots" content="noindex, follow">/essays/10k-code-zh/(wrong-language URL, no translation) →<meta name="robots" content="noindex, follow">/zh/essays/decision-game/(en content at zh URL) → noindex<title>, canonical, hreflang triple, article OG tagsDev server smoke-tested too: real SSR HTML carries the right
langper locale.Out of scope for this PR (per the plan)
next/ogor default brand card) — P1 item.www.feitong.phd.Notes
redirect()from'next/navigation'emits a meta-refresh shim underoutput: 'export'. The newnoindexensures crawlers don't index those intermediates regardless.Test plan
/essays/decision-game/(positive case — should validate) and/essays/10k-code-zh/(should show noindex correctly).en+zh+x-defaultand thatzh-only-at-en-urlpages are noindex.https://www.feitong.phd/zh/shows<html lang="zh">(the whole point of this PR).🤖 Generated with Claude Code