Skip to content

feat(seo): language + canonical signals (per-locale <html lang>, x-default, fallback noindex)#110

Merged
ftvision merged 1 commit into
masterfrom
feat/seo-lang-signals
May 14, 2026
Merged

feat(seo): language + canonical signals (per-locale <html lang>, x-default, fallback noindex)#110
ftvision merged 1 commit into
masterfrom
feat/seo-lang-signals

Conversation

@ftvision
Copy link
Copy Markdown
Owner

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:

app/
├── globals.css, sitemap.ts, robots.ts    (unchanged, file-convention roots)
├── (en)/
│   ├── layout.tsx                        (owns <html lang="en">)
│   ├── page.tsx                          (/)
│   ├── about/, essays/, periodics/, series/
└── (zh)/
    ├── layout.tsx                        (owns <html lang="zh">)
    └── zh/
        ├── page.tsx                      (/zh)
        ├── about/, essays/, periodics/, series/

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. LanguageProvider takes initialLanguage from 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.canonical explicitly set everywhere.
  • alternates.languages['x-default'] added on every page. Points at the EN URL by convention.
  • Cross-language fallback states are now 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 declares robots: { index: false }. Stops Google from indexing dozens of near-duplicate "wrong language" landing pages. Symmetric on the zh side.
  • Replaced "杨飞同" in (zh)/zh/about/page.tsx description with "Feitong Yang" (consistent with the prior PR's branding decision).

Tests

  • __tests__/layout.test.ts now covers both root layouts via describe.each. Asserts default + template title structure, metadataBase, x-default hreflang.
  • __tests__/home-page.test.tsx import path updated for the move.
  • All 164 tests pass.

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
  • Properly indexed essays show full <title>, canonical, hreflang triple, article OG tags

Dev server smoke-tested too: real SSR HTML carries the right lang per locale.

Out of scope for this PR (per the plan)

  • JSON-LD (Article / Person / WebSite / BreadcrumbList) — P1 item, separate PR.
  • OG images (per-essay via next/og or default brand card) — P1 item.
  • RSS feed — P1 item.
  • Favicons / app icons — P1 item.
  • Search Console verification + analytics — P2 item; can't be done until DNS is live on www.feitong.phd.

Notes

Test plan

  • CI passes
  • After deploy, run Rich Results Test against /essays/decision-game/ (positive case — should validate) and /essays/10k-code-zh/ (should show noindex correctly).
  • After deploy, run Schema/hreflang inspection or Screaming Frog → check that every essay reports en + zh + x-default and that zh-only-at-en-url pages are noindex.
  • Verify view-source on https://www.feitong.phd/zh/ shows <html lang="zh"> (the whole point of this PR).

🤖 Generated with Claude Code

…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>
@ftvision ftvision merged commit 240c2d3 into master May 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant