Skip to content

feat(seo): per-page OG cards via next/og#112

Merged
ftvision merged 4 commits into
masterfrom
feat/seo-og-images
May 14, 2026
Merged

feat(seo): per-page OG cards via next/og#112
ftvision merged 4 commits into
masterfrom
feat/seo-og-images

Conversation

@ftvision
Copy link
Copy Markdown
Owner

Stacked on #111 — please merge #111 first, then this PR's base updates to master.

Summary

Generates a 1200×630 PNG card for every essay/periodic/series at build time using Next 14's next/og (Satori under the hood). Plus site-default cards per locale, plus twitter.card: summary_large_image everywhere.

Closes P1 item #9 from the audit. Without OG images, social previews on Slack/LinkedIn/iMessage/X are text-only and CTR drops sharply.

Sample cards

EN essay (/essays/decision-game/):

  • ALGO MIND | TECHNICAL kicker with amber accent dot
  • "The Era of Decision Games" big title
  • "Feitong Yang" byline, "www.feitong.phd" footer

ZH essay (/zh/essays/decision-game-zh/):

  • 思算 | 技术 kicker
  • "决策博弈的时代" — CJK renders correctly via Satori's fallback fonts (no custom font bundle needed)

EN/ZH home: brand-only kicker, "Essays on AI, Product, Engineering" / "关于 AI、产品与工程的随笔"

Architecture

  • components/seo/og-card.tsx — shared JSX template, ~120 lines. Satori implements a subset of CSS (flexbox + basic typography + solid colors/gradients), so all styles are inline.
  • 6 opengraph-image.tsx files at each detail route's [slug]/. Each exports generateStaticParams() so cards pre-render at build under output: 'export'.
  • 2 layout-level opengraph-image.tsx files (one per route group) covering home + index pages.
  • All routes use export const dynamic = 'force-static'.

Override path

Added optional image: to essay/periodic/series frontmatter. When set, generateMetadata explicitly overrides openGraph.images and twitter.images for that page. So for decision-game you can either:

  • Use the autogenerated card (current default)
  • Or add image: /images/decision-game-hero.jpg to its frontmatter — but the hero is 1400×763 (1.83:1), close to but not quite the OG spec 1200×630 (1.91:1). It would work with slight letterboxing.

My recommendation: stick with the autogenerated card. Title-bearing cards beat wordless heroes for CTR.

Static-export filename fix

Next 14's opengraph-image.tsx writes PNGs without a .png extension. Its dev/preview server adds the right Content-Type from a route manifest, but with output: 'export' we ship raw files to GitHub Pages, which types files by extension and serves unknown ones as application/octet-stream — and social scrapers reject those.

Fix: scripts/postbuild-og.mjs walks out/, renames each opengraph-image-XXX to opengraph-image-XXX.png, and rewrites references in HTML/feed/sitemap. Wired as a postbuild step in package.json (the build script now runs next build && node scripts/postbuild-og.mjs).

Verified locally with npx serve: PNGs now get Content-Type: image/png.

Verified

  • /essays/decision-game/opengraph-image-pxgkh.png — 1200×630 RGBA, Content-Type: image/png
  • /zh/essays/decision-game-zh/... — CJK rendering correct
  • /opengraph-image-35z9bs.png (en home), /opengraph-image-35zqtj.png (zh home)
  • <meta property="og:image" content="..."> and <meta name="twitter:card" content="summary_large_image"> on all detail pages
  • 94 PNGs emitted, all with .png, all referenced correctly in HTML
  • 164 tests pass

Open questions

  1. Typography: Cards use Satori's fallback font (looks like DejaVu Sans). It's clean but doesn't match the NYT serif theme. Want me to bundle a font file (Inter or Source Serif) in a follow-up? That's an additional ~200KB build asset.
  2. Favicons: still pending. Want me to do them next? Quick options:
    • Text-mark "AM" / "思" on a colored square (I generate)
    • You supply an icon
  3. Decision-game hero: the existing 1400×763 image isn't quite OG-spec but would work. Want me to add image: /images/decision-game-hero.jpg to its frontmatter so it overrides the autogenerated card? Or stick with the generated card?

Test plan

🤖 Generated with Claude Code

Feitong Yang and others added 4 commits May 13, 2026 18:08
Adds schema.org JSON-LD to every page and Atom feeds at /feed.xml and
/zh/feed.xml. Closes P1 items #8 (JSON-LD) and #12 (RSS) from the
audit. JSON-LD is the highest-leverage GEO signal — AI answer engines
(Perplexity, ChatGPT Search, AI Overviews) rely on schema to confirm
authorship and content type.

JSON-LD shapes:

- Layouts (both EN and ZH): a single @graph with Person + Organization
  + WebSite, connected by @id cross-references. The Person includes
  jobTitle, worksFor, alumniOf, and sameAs (LinkedIn, Google Scholar,
  NeuroTree) — these are the identity signals AI engines use for entity
  resolution.
- Essay and periodic detail pages: BlogPosting + BreadcrumbList.
  BlogPosting refs Person and Organization by @id, not by inlining
  them, so the entity stays singular across pages.
- Series detail pages: CollectionPage + BreadcrumbList.
- Wrong-language fallback pages (already noindex) intentionally do NOT
  emit BlogPosting/Breadcrumb — only the layout's site graph if the
  layout renders at all.

Architecture:

- lib/jsonld.ts holds all schema builders. Server-only, no client deps.
- components/seo/JsonLd.tsx is a server component that renders the
  <script type="application/ld+json"> tag with safe escaping
  (replaces "<" with "<" to prevent script-injection breakout).
- One <JsonLd> per top-level schema so each block is independently
  debuggable in the Rich Results Test.

Atom feeds:

- lib/feed.ts builds RFC 4287 Atom XML from getAllEssays + getAllPeriodics
  for the requested locale, sorted newest first, limited to 20 entries.
- app/feed.xml/route.ts and app/(zh)/zh/feed.xml/route.ts return the
  XML. Both use `export const dynamic = 'force-static'` so they
  pre-render at build time under output: 'export'.
- <link rel="alternate" type="application/atom+xml"> added to both
  root layouts so feed readers auto-discover.

Verified in build output:
- /essays/decision-game/ has 3 JSON-LD blocks: @graph, BlogPosting,
  BreadcrumbList. BlogPosting references Person via @id.
- /zh/essays/decision-game-zh/ same shape with zh-CN inLanguage.
- /essays/10k-code-zh/ (noindex fallback) emits only the site @graph
  from the layout, no page-level schema.
- /feed.xml is 2.1 KB, /zh/feed.xml is 8.6 KB, both valid Atom XML.
- 164 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Generates a 1200x630 PNG per essay/periodic/series, plus a site-default
card for each locale, plus Twitter summary_large_image upgrades.

Closes P1 item #9 from the audit. Without OG images, social previews
on Slack/LinkedIn/iMessage/X are text-only — CTR drops sharply.

How it works:

- Shared OG card template at components/seo/og-card.tsx — JSX rendered
  by Satori (via next/og's ImageResponse). 1200x630, dark theme, brand
  kicker + amber accent dot, big title, byline + domain footer.
- Six per-detail-route opengraph-image.tsx files:
    app/(en)/essays/[slug]/, app/(zh)/zh/essays/[slug]/
    app/(en)/periodics/[slug]/, app/(zh)/zh/periodics/[slug]/
    app/(en)/series/[slug]/, app/(zh)/zh/series/[slug]/
  Each exports generateStaticParams() so the cards pre-render at build
  time under output: 'export'.
- Two layout-level opengraph-image.tsx files (one per route group) for
  the home page and any index that doesn't have its own.
- All use `export const dynamic = 'force-static'` so the route handler
  runs once at build.

Override path:

- Optional `image:` field added to essay/periodic/series frontmatter.
  When set, generateMetadata explicitly overrides openGraph.images and
  twitter.images for that page. Lets you hand-pick a card (e.g. the
  in-essay hero image for decision-game) when the autogenerated one
  isn't right.

Static-export filename fix:

- Next 14's `opengraph-image.tsx` writes PNGs without a `.png`
  extension. The dev server fixes this with a route manifest, but
  under output: 'export' the raw file ships to GitHub Pages, which
  types files by extension and serves unknown ones as
  application/octet-stream. Social scrapers reject those.
- scripts/postbuild-og.mjs renames each opengraph-image-* to
  opengraph-image-*.png and rewrites references in HTML/feed/sitemap.
  Wired as a postbuild step in package.json.

Twitter:

- twitter.card upgraded to summary_large_image on all detail pages.
  When essay.image is set, twitter.images carries the override.

Verified:
- /essays/decision-game/opengraph-image-pxgkh.png — 1200x630 RGBA,
  Content-Type image/png after postbuild rename.
- /zh/essays/decision-game-zh/... — CJK renders correctly with Satori
  fallback fonts; no custom font bundle needed.
- /opengraph-image-35z9bs.png (en home) and /opengraph-image-35zqtj.png
  (zh home) — both reference correctly from / and /zh/.
- 94 PNG files emitted, 8 unique route templates.
- 164 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Redesign of the OG card from generic dark dev-tool style to a
New-Yorker-inspired editorial composition that reads like a print
publication rather than a SaaS announcement. Closes the design issue
from #112's review: the autogenerated cards were competent but
indistinguishable from any other tech-blog OG asset.

What changed visually:

- Surface: warm cream #f5f1e8 (not pure white, not the prior near-black)
- Ink: softened near-black #1a1814 (warm-tinted neutral, not #000)
- Accent: nyt-blue #326891 — sourced from color.accent.primary in the
  NYT light theme, not the prior amber (which is the warning token).
  Brings the card into brand cohesion with the rest of the site.
- Type: Source Serif Pro (EN) and Noto Serif SC (ZH), bundled via
  @fontsource and loaded into Satori at build time. Bold serif title,
  italic byline in EN, normal weight in ZH.
- Composition: centered classical. Masthead + kicker at top with
  hairline rule; dominant title in the middle; hairline rule and
  italic byline + domain footer at the bottom. Generous breathing
  room.

Implementation:

- lib/og-fonts.ts loads woff binaries from node_modules at build time.
  Latin: 3 weights × ~28KB = 84KB. CJK: 2 weights × ~2MB = 4MB.
  Build-time only, never shipped to the browser.
- components/seo/og-card.tsx rewritten. Uses borders (not separate
  hairline divs) for the rules since Satori doesn't propagate
  width: 100% through column flex parents the way browsers do.
- All 8 opengraph-image.tsx routes now pass fonts to ImageResponse.

Storybook:

- components/seo/OgCard.stories.tsx renders the card via JSX in the
  browser using the same @fontsource @font-face declarations. Useful
  for fast iteration on composition and typography. Browser rendering
  is not pixel-identical to Satori but close enough for design work.
  Stories: 5 EN variants (essay / long-title essay / periodic /
  series / home), 4 ZH variants, side-by-side comparison, and a
  pixel-accurate 1:1 render.

Verified in build output:
- 94 PNG cards regenerated, all readable typography.
- Sample: cream surface, nyt-blue ALGO MIND | TECHNICAL kicker,
  Source Serif Pro bold title "The Era of Decision Games", italic
  "by Feitong Yang" byline, WWW.FEITONG.PHD small-caps footer.
- ZH sample: Noto Serif SC "决策博弈的时代", 思算 kicker, proper
  CJK serif rendering.
- 164 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Earlier commit used eyeballed cream/warm-ink values that didn't
match the design system. Replacing with the real tokens from
packages/tokens/src/themes/nyt/light.json so the card reads
identical-tone to the site:

- surface: #f5f1e8 (cream)        → #ffffff  (bg.primary → white)
- ink:     #1a1814 (warm-ink)     → #18181b  (text.primary → gray.900)
- muted:   #6b6457 (warm-muted)   → #71717a  (text.muted → gray.500)
- rule:    #b6c3d4 (tinted blue)  → #e4e4e7  (border.default → gray.200)
- accent:  #326891 (kept)         — accent.primary (nyt-blue) was already correct

Storybook description and component comment updated to reflect the
token mapping instead of "cream surface".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@ftvision ftvision changed the base branch from feat/seo-jsonld-rss to master May 14, 2026 19:12
@ftvision ftvision merged commit e1d125a 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