Skip to content

feat(seo): related-essays footer for internal linking#113

Merged
ftvision merged 4 commits into
masterfrom
feat/seo-related-essays
May 14, 2026
Merged

feat(seo): related-essays footer for internal linking#113
ftvision merged 4 commits into
masterfrom
feat/seo-related-essays

Conversation

@ftvision
Copy link
Copy Markdown
Owner

Summary

Adds a "Related essays" block at the bottom of every essay detail page (EN and ZH), computed from shared topic tags with a recency fallback. Internal linking is the most undervalued lever for both SEO and GEO — and it costs almost nothing to ship.

Why this matters

  • SEO: distributes link equity across essays, improves crawl depth, lifts orphan pages. Every essay was previously a dead end. Search engines now traverse from one to the next via the related block.
  • GEO: AI answer engines (Perplexity, ChatGPT Search, AI Overviews) follow on-page links to assess authority structure. More on-page links = more contexts where each essay can be cited from a topically adjacent query.
  • UX: readers stay on site longer; one essay invites the next.

Algorithm

getRelatedEssays(slug, { limit = 3 }):

  1. Filter to same language, non-draft, not the essay itself, not its translation in either direction.
  2. Score each candidate by count of shared topic tags with the source.
  3. Sort by score desc, then date desc.
  4. Take the top limit.
  5. If fewer than limit essays share a topic, fill the remainder with the most-recent essays in the same language. Avoids an empty block for niche topics.

What lands

  • lib/essays.tsgetRelatedEssays() helper
  • components/essay/RelatedEssays.tsx — server component, hairline-rule editorial layout matching the OG card idiom
  • components/essay/RelatedEssays.stories.tsx — 4 Storybook variants (English, Chinese, single result, empty)
  • app/(en)/essays/[slug]/page.tsx and app/(zh)/zh/essays/[slug]/page.tsx — integration
  • i18n: essay.related.heading in en.json (Related essays) and zh.json (相关文章)
  • 5 new tests covering ranking, exclusion of self/translation, language filter, limit, missing-source

Verified

  • EN /essays/decision-game/ renders the block with proper related items
  • ZH /zh/essays/decision-game-zh/ renders the block with proper related items
  • Server-rendered HTML carries the full markup (Googlebot will see it)
  • 169 tests pass
  • Build + postbuild OG rename still clean

Out of scope (deferred)

  • Periodics and series detail pages don't get a related block — series→series is usually 1:1 with topics, and periodics→periodics is less natural. Easy to add later if useful.
  • "Continue reading this series" back-link (when an essay belongs to a series) — would need a series field on essay frontmatter or a series index that lists members. Out of scope for this PR.

Test plan

  • CI passes
  • After merge + deploy, open any essay → scroll to bottom → see "Related essays" block with 3 items
  • Inspect view-source on a real essay; confirm the <aside aria-labelledby="related-essays-heading"> is in the SSR HTML
  • Storybook → Essay / RelatedEssays shows all 4 variants

🤖 Generated with Claude Code

Feitong Yang and others added 4 commits May 14, 2026 13:11
Adds a "Related essays" block at the bottom of every essay detail page,
computed from shared topics with a recency fallback. Internal linking is
the single most undervalued lever for both SEO and GEO:

- SEO: distributes link equity across essays, improves crawl depth, lifts
  orphan pages. Previously every essay was a dead end.
- GEO: gives AI engines more contexts to encounter each essay, increasing
  the chance an essay is cited from a topically adjacent query.
- UX: real readers stay on site longer.

Algorithm (lib/essays.ts → getRelatedEssays):

  1. Filter to same language, non-draft, not the essay itself, not the
     essay's translation (in either direction).
  2. Score each candidate by count of topics it shares with the source.
  3. Sort by score desc, then date desc.
  4. Take top `limit` (default 3).
  5. If fewer than `limit` candidates share a topic, fill the remainder
     with the most recent essays in the same language. Avoids an empty
     block for niche topics.

Component (components/essay/RelatedEssays.tsx):

  Server component. Renders nothing when the essays array is empty. Each
  item shows topic eyebrow, title (serif, link-on-hover), description
  (2-line clamp), and date + reading time. Composition uses the same
  hairline-rule / restrained-typography idiom as the OG card so the
  on-page block and the social card share a visual language.

i18n:

  Added `essay.related.heading` to en.json and zh.json.

Tests: 5 new tests covering ranking, exclusion of self/translation,
language filter, limit, and missing-source. 169 total tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Refines the related-essays algorithm based on review feedback. The
previous version recommended unrelated essays via recency fallback,
which was the right complaint:

- decision-game (topics: technical, ai) recommended a career essay,
  surfaced only because there weren't 3 strict-overlap candidates and
  the algorithm padded with most-recent essays. That dilutes link
  equity, signals "automated content widget" to search and AI engines,
  and reads as noise to readers.

Changes:

1. Author-curated override via `relatedTo: [slug, slug]` frontmatter.
   When set, those slugs ARE the related list, in order. Slugs that
   don't resolve, that point at the source itself, that cross
   languages, or that point at drafts are dropped silently. Capped at
   `limit`. Manual curation is the strongest editorial signal — both
   for readers and for AI engines weighing relevance.

2. Auto-rank replaces raw shared-topic count with **Jaccard
   similarity** (|intersection| / |union|) plus a +0.15 same-type
   bonus. Jaccard naturally penalizes essays that share one
   common-but-cheap tag while spanning many unrelated topics.

3. **No recency fallback.** Candidates that share zero topics with the
   source are dropped. If no candidate qualifies, the block hides
   itself. Better silence than noise.

Why this is better for SEO + GEO, not worse:

- Link equity divides by number of outbound links. 1-2 strong related
  links carry more weight per link than 3 mediocre ones.
- Topical-relevance is a stronger ranking signal than link density.
  Google's HCU and AI answer engines actively penalise patterns that
  read as automated related-content widgets.
- Manual `relatedTo` gives the destination essay a human-vouched
  inbound link, which AI engines weight more heavily than algorithmic
  ones.

Tests: 5 new cases — type-match tiebreaker, curated list order +
drops, curated cap, and explicit "no recency fallback" assertion.
174 total tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the card-list layout (eyebrow + title + description clamp +
date footer with divider lines) that read like a generic feed widget,
with a minimalist numbered-list composition matching the OG card's
editorial register.

Composition:

  RELATED

  01    The Era of Decision Games            18 min
  02    Career Reflection — 2023             12 min
  03    Negotiating Software Engineering      8 min

- Small uppercase "RELATED" eyebrow (matches the OG card kicker style).
- Numbered <ol> with tabular-figure prefix in muted ink.
- Serif title flex-grows; reading time pinned right; hairline rule
  between rows via border-b on each <li>.
- No description, no date, no card boxes. Reads like a back-of-book
  "Further Reading" appendix.

Translations shortened to match the eyebrow style:
  essay.related.heading: "Related essays" → "Related"
                          "相关文章"         → "相关"

The semantic <h2> still exists for accessibility (aria-labelledby keeps
working); only its visible text is short.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous render had three visible problems caused by the same
root: the RelatedEssays block was rendered inside .essay-content,
which applies an editorial typography cascade (h2 → huge headline,
a → underlined link, ol → decimal markers with padding-left).
Result: duplicated numbering ("1." from .essay-content ol AND "01"
from my decorative span), an oversized "RELATED" eyebrow, and
underlined links throughout.

Fixes:

- EssayLayout: add a `footer` prop that renders OUTSIDE the
  .essay-content div. The footer wrapper has no .essay-content class
  so its descendants are not subject to the article typography
  cascade. Width matches the article body (38-42rem responsive) so
  the block aligns visually below the content.

- RelatedEssays: drop the decorative `<span>01</span>` prefix per
  feedback. Keep <ol> for semantics, restore list-decimal +
  marker:text-figure-muted so the native numbering ("1.", "2.")
  renders cleanly in the proper muted color. Add no-underline on
  the Link to defeat any remaining anchor-underline inheritance.

- Both essay detail pages: pass RelatedEssays via EssayLayout's new
  `footer` prop instead of as a child of <MDXRemote>.

Now renders as a minimalist editorial list:

  相关
  1.  程序扩展人类智能                   阅读时间 2 分钟
  2.  10000行C++之后对C++的思考           阅读时间 15 分钟
  3.  一些细节看程序员的质量和习惯       阅读时间 2 分钟

174 tests pass; build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@ftvision ftvision merged commit daaf13b 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