feat(seo): per-page OG cards via next/og#112
Merged
Merged
Conversation
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>
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.
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, plustwitter.card: summary_large_imageeverywhere.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 | TECHNICALkicker with amber accent dotZH essay (
/zh/essays/decision-game-zh/):思算 | 技术kickerEN/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.opengraph-image.tsxfiles at each detail route's[slug]/. Each exportsgenerateStaticParams()so cards pre-render at build underoutput: 'export'.opengraph-image.tsxfiles (one per route group) covering home + index pages.export const dynamic = 'force-static'.Override path
Added optional
image:to essay/periodic/series frontmatter. When set,generateMetadataexplicitly overridesopenGraph.imagesandtwitter.imagesfor that page. So for decision-game you can either:image: /images/decision-game-hero.jpgto 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.tsxwrites PNGs without a.pngextension. Its dev/preview server adds the rightContent-Typefrom a route manifest, but withoutput: 'export'we ship raw files to GitHub Pages, which types files by extension and serves unknown ones asapplication/octet-stream— and social scrapers reject those.Fix:
scripts/postbuild-og.mjswalksout/, renames eachopengraph-image-XXXtoopengraph-image-XXX.png, and rewrites references in HTML/feed/sitemap. Wired as a postbuild step inpackage.json(thebuildscript now runsnext build && node scripts/postbuild-og.mjs).Verified locally with
npx serve: PNGs now getContent-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.png, all referenced correctly in HTMLOpen questions
image: /images/decision-game-hero.jpgto its frontmatter so it overrides the autogenerated card? Or stick with the generated card?Test plan
https://www.feitong.phd/essays/decision-game/into Slack DM yourself — should show the autogenerated card🤖 Generated with Claude Code