feat(og): dynamic package-themed OG images#835
Conversation
…in function bundle
✅ Deploy Preview for tanstack ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughRemoves static ChangesDynamic OG image generation
Sequence DiagramsequenceDiagram
participant Client as Client (Browser)
participant Route as API Route\n/api/og/$library.png
participant Generator as generateOgImageResponse()
participant Assets as loadOgAssets()
participant Colors as getAccentColor()
participant Template as buildOgTree()
participant Takumi as Takumi ImageResponse
Client->>Route: GET /api/og/query.png?title=Overview
activate Route
Route->>Generator: generateOgImageResponse({libraryId:'query', title:'Overview'})
activate Generator
Generator->>Generator: findLibrary('query')
Generator->>Assets: loadOgAssets()
activate Assets
Assets-->>Generator: fonts + island Buffer
deactivate Assets
Generator->>Colors: getAccentColor('query')
Colors-->>Generator: accentColor
Generator->>Template: buildOgTree({libraryName, accentColor, ...})
activate Template
Template-->>Generator: ReactElement
deactivate Template
Generator->>Takumi: ImageResponse(ReactElement, { fonts, size:1200x630 })
activate Takumi
Takumi-->>Generator: PNG response
deactivate Takumi
Generator-->>Route: PNG response
deactivate Generator
Route->>Client: PNG + Content-Type/Cache-Control
deactivate Route
Estimated Code Review Effort🎯 4 (Complex) | ⏱️ ~55 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (9)
src/server/og/colors.ts (1)
6-24: Optional: tighten typing toPartial<Record<LibraryId, string>>.Using
Record<string, string>forgoes the compile-time check that keys correspond to realLibraryIds — so a typo or a renamed/removed library id silently falls back to the default without a TS error.Proposed refactor
-const LIBRARY_ACCENT_COLORS: Record<string, string> = { +const LIBRARY_ACCENT_COLORS: Partial<Record<LibraryId, string>> = {🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/server/og/colors.ts` around lines 6 - 24, Replace the overly-broad type of LIBRARY_ACCENT_COLORS with a type keyed by LibraryId to catch typos/removed ids: change its declaration from Record<string,string> to Partial<Record<LibraryId,string>> (or Readonly<...> if immutability is desired), and import or reference the existing LibraryId type so the compiler enforces allowed keys; keep existing keys/values unchanged and ensure any missing keys fall back to current runtime defaults.src/server/og/template.tsx (1)
117-123:hexToRgbasilently producesNaNfor non-6-digit hex.All current callers pass controlled 6-digit values from
colors.ts, so this is defensive only, but a stray#fffor invalid string would producergba(NaN, NaN, NaN, ...)and break the background gradient without warning. Consider either validating and falling back to the default, or normalizing 3-digit hex.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/server/og/template.tsx` around lines 117 - 123, The hexToRgba function can produce NaN for non-6-digit inputs; update hexToRgba to validate and normalize input: strip '#', if length === 3 expand shorthand (e.g. 'abc' -> 'aabbcc'), if length === 6 parse as before, otherwise return a safe fallback (e.g. defaultColor or a hardcoded rgba black/transparent) or throw/log an error; reference the hexToRgba function to locate the fix and ensure callers keep the same signature (hexToRgba(hex: string, alpha: number): string).src/routes/$libraryId/$version.docs.framework.$framework.examples.$.tsx (1)
131-134: Minor: duplicated title/description string construction.The title and description strings are built identically for
seo(...)above (lines 125–130) and again forogImageUrl. Consider hoisting them into localconsts to avoid drift if one side is later edited.♻️ Suggested refactor
head: ({ params }) => { const library = getLibrary(params.libraryId) + const exampleName = slugToTitle(params._splat || '') + const frameworkName = capitalize(params.framework) + const ogTitle = `${frameworkName} ${library.name} ${exampleName} Example` + const ogDescription = `An example showing how to implement ${exampleName} in ${frameworkName} using ${library.name}.` return { meta: seo({ - title: `${capitalize(params.framework)} ${library.name} ${slugToTitle( - params._splat || '', - )} Example | ${library.name} Docs`, - description: `An example showing how to implement ${slugToTitle( - params._splat || '', - )} in ${capitalize(params.framework)} using ${library.name}.`, - image: ogImageUrl(library.id, { - title: `${capitalize(params.framework)} ${library.name} ${slugToTitle(params._splat || '')} Example`, - description: `An example showing how to implement ${slugToTitle(params._splat || '')} in ${capitalize(params.framework)} using ${library.name}.`, - }), + title: `${ogTitle} | ${library.name} Docs`, + description: ogDescription, + image: ogImageUrl(library.id, { title: ogTitle, description: ogDescription }), noindex: library.visible === false, }), } },🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/routes/`$libraryId/$version.docs.framework.$framework.examples.$.tsx around lines 131 - 134, The title/description are duplicated between the seo(...) call and the ogImageUrl(...) call; hoist them into local constants (e.g., const title = ..., const description = ...) computed once using params, capitalize, slugToTitle and library.name, then replace the inline templates passed to seo(...) and ogImageUrl(...) with those constants to ensure a single source of truth and avoid drift (update occurrences around the seo and ogImageUrl calls).tests/smoke.ts (2)
60-66: Consider adding a 404 case to the OG smoke tests.The PR test plan calls out "404 for unknown libraries in prod". Adding one case that hits e.g.
/api/og/not-a-library.pngand assertsresponse.status === 404would guard the error branch in the route handler against regressions (currently only happy paths are tested).🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/smoke.ts` around lines 60 - 66, Add a 404 smoke test to the OG tests by extending the ogTests array (ImageTestCase) with a case that targets a non-existent library path, e.g. { name: 'OG image · unknown library (404)', path: '/api/og/not-a-library.png' }, and update the test runner assertion for that case to expect response.status === 404 (instead of image content checks used for happy paths) so the route handler's error branch is exercised.
170-171: Minor: shared pass/fail counters make the intermediate totals misleading.
passed/failedare not reset between the HTML block and the OG block, so the "X passed, Y failed" printed at line 224 is actually a cumulative total but reads as if it's just the OG results. Consider separate counters per block (or only printing a single final summary) to avoid confusion when a failure occurs.♻️ Suggested change
- console.log(`\n${passed} passed, ${failed} failed\n`) + console.log(`\nHTML: ${passed} passed, ${failed} failed\n`) ... - console.log(`\n${passed} passed, ${failed} failed\n`) + console.log(`\nTotal: ${passed} passed, ${failed} failed\n`)Also applies to: 184-184, 224-224
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/smoke.ts` around lines 170 - 171, The shared counters passed and failed are reused for both the HTML block and the OG block, making the intermediate "X passed, Y failed" message misleading; update the test to use separate counters (e.g., passedHtml/failedHtml and passedOg/failedOg) or reset passed/failed before starting the OG block so the printed totals reflect only that block's results; locate and modify the variables passed and failed and the summary prints that reference them near the HTML and OG test sections to ensure each block reports its own counts.src/utils/og.ts (1)
14-25: Consider clampingtitle/descriptionclient-side to match server limits.
generateOgPngclamps to 80/160 chars server-side, but callers pass raw loader values intoogImageUrl, so the URL embedded in<meta property="og:image">can be arbitrarily long. Two small downsides:
- Inflates the rendered HTML head (every docs page carries this meta tag).
- Every unique pre-clamp variant produces a distinct cache key at the CDN even though many render to the same PNG, undermining the intent of the server-side clamp "to limit cache keys" mentioned in the PR description.
Clamping here (to the same 80/160 constants exported from
generate.server.ts, or duplicating them in a shared module) keeps URLs bounded and makes CDN cache keys stable.♻️ Suggested change
+const MAX_TITLE = 80 +const MAX_DESCRIPTION = 160 + +function clamp(text: string, max: number): string { + const t = text.trim() + if (t.length <= max) return t + return t.slice(0, max - 1).trimEnd() + '…' +} + export function ogImageUrl( libraryId: LibraryId, options: OgImageOptions = {}, ): string { const params = new URLSearchParams() - if (options.title) params.set('title', options.title) - if (options.description) params.set('description', options.description) + if (options.title) params.set('title', clamp(options.title, MAX_TITLE)) + if (options.description) + params.set('description', clamp(options.description, MAX_DESCRIPTION))Ideally the
MAX_*constants live in one place and are imported by both sides.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/utils/og.ts` around lines 14 - 25, The ogImageUrl helper currently forwards raw options which can produce arbitrarily long query strings; clamp the title and description client-side to the same limits used by the server-side generator (e.g., the MAX_* limits from generateOgPng in generate.server.ts) before building the URL so the meta og:image query is bounded and CDN cache keys remain stable. Update ogImageUrl to import or duplicate the MAX_TITLE/MAX_DESCRIPTION constants (or call a shared clamp utility) and truncate options.title and options.description to those lengths prior to creating URLSearchParams; reference the ogImageUrl function and the generateOgPng/MAX constants in generate.server.ts to keep both sides consistent.scripts/og-preview.ts (1)
29-37: Minor: inconsistent error reporting between variants.The landing variant logs
[skip] ${lib.id}: ${kind}on error, but the docs variant silentlycontinues. For a dev-facing preview script, logging both helps diagnose misconfiguration.♻️ Suggested change
- if ('kind' in (docs as Record<string, unknown>)) continue + if ('kind' in (docs as Record<string, unknown>)) { + console.warn(`[skip docs] ${lib.id}: ${(docs as any).kind}`) + continue + }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@scripts/og-preview.ts` around lines 29 - 37, The docs branch silently skips when generateOgPng returns an error-like object; update the docs handling to log the same diagnostic as the landing variant instead of just continuing: after calling generateOgPng (the docs constant) check for the error shape ('kind' in docs as Record<string,unknown>) and log a message like `[skip] ${lib.id}: ${kind}` (or include the returned kind/value) before continuing, referencing generateOgPng, docs, docsPath and lib.id so the script reports failures consistently.src/server/og/generate.server.ts (1)
74-77: Clamp boundary nit.
slice(0, max - 1) + '…'yields exactlymaxchars in the common case, buttrimEnd()can shorten it further so the final length is ≤max. This is fine for cache-key bounding, just note that the truncation point can land mid-word (e.g., "quick bro…"). If you prefer word-boundary truncation, you could break on the last space beforemax. Optional.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/server/og/generate.server.ts` around lines 74 - 77, clampText currently slices to max-1 and appends an ellipsis which can cut mid-word and, because of trimEnd(), may produce a string shorter than max; update clampText to prefer truncation at the last whitespace before the max boundary: in function clampText find the initial slice (text.slice(0, max - 1)), trimEnd it, then if the original text length > max search for the last space in that slice and, if found, slice to that space instead before appending '…' so truncation lands on a word boundary while still ensuring the result is ≤ max characters.src/routes/api/og/$library[.png].ts (1)
35-35: Unnecessarynew Uint8Array(result)copy.
resultis a NodeBuffer, which is already aUint8Arraysubclass and a validBodyInit. Wrapping it innew Uint8Array(result)allocates a copy on every request on the hot path.♻️ Proposed simplification
- return new Response(new Uint8Array(result), { + return new Response(result, {🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/routes/api/og/`$library[.png].ts at line 35, The Response creation unnecessarily copies the Node Buffer by wrapping it in new Uint8Array(result); change the Response construction to pass the existing Buffer/Uint8Array directly (i.e., use result as the BodyInit) so no heap-copy occurs, ensuring the variable result (from the image generation flow) is used directly when constructing the Response.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/routes/api/og/`$library[.png].ts:
- Around line 29-40: Wrap the call to generateOgPng in a try/catch inside the
route handler so any exceptions from satori/resvg are caught; on catch return a
500 Response with headers that prevent caching (e.g., Cache-Control: no-store or
a very short TTL) instead of letting the error propagate, and preserve the
existing successful response path that uses CACHE_HEADERS; reference
generateOgPng and CACHE_HEADERS so you locate the call and replace it with a
try/catch that returns a non-cacheable 500 on error.
In `@src/server/og/generate.server.ts`:
- Around line 52-65: The Inter font weight is mis-registered and the template
uses a mismatched weight: in the fonts array in generate.server.ts change the
registration for Inter-Regular from weight 700 to weight 400, and then update
the description text style in src/server/og/template.tsx (the element using
fontWeight: 700 around line ~96) to use fontWeight: 400 (or adjust both files to
the intended consistent weight if 700 was desired); ensure the two places (fonts
registration in generate.server.ts and the description fontWeight in
template.tsx) match so Satori finds the correct weight (references: fonts array
in generate.server.ts and the description node in template.tsx).
In `@src/server/og/template.tsx`:
- Around line 47-56: Add an empty alt attribute to the decorative island image
so the JSX linter passes: update the <img> that uses props.islandDataUrl (with
ISLAND_SIZE, WIDTH, HEIGHT) in template.tsx to include alt="" (empty string) to
mark it as decorative; no other behavior changes are needed since Satori ignores
alt text.
---
Nitpick comments:
In `@scripts/og-preview.ts`:
- Around line 29-37: The docs branch silently skips when generateOgPng returns
an error-like object; update the docs handling to log the same diagnostic as the
landing variant instead of just continuing: after calling generateOgPng (the
docs constant) check for the error shape ('kind' in docs as
Record<string,unknown>) and log a message like `[skip] ${lib.id}: ${kind}` (or
include the returned kind/value) before continuing, referencing generateOgPng,
docs, docsPath and lib.id so the script reports failures consistently.
In `@src/routes/`$libraryId/$version.docs.framework.$framework.examples.$.tsx:
- Around line 131-134: The title/description are duplicated between the seo(...)
call and the ogImageUrl(...) call; hoist them into local constants (e.g., const
title = ..., const description = ...) computed once using params, capitalize,
slugToTitle and library.name, then replace the inline templates passed to
seo(...) and ogImageUrl(...) with those constants to ensure a single source of
truth and avoid drift (update occurrences around the seo and ogImageUrl calls).
In `@src/routes/api/og/`$library[.png].ts:
- Line 35: The Response creation unnecessarily copies the Node Buffer by
wrapping it in new Uint8Array(result); change the Response construction to pass
the existing Buffer/Uint8Array directly (i.e., use result as the BodyInit) so no
heap-copy occurs, ensuring the variable result (from the image generation flow)
is used directly when constructing the Response.
In `@src/server/og/colors.ts`:
- Around line 6-24: Replace the overly-broad type of LIBRARY_ACCENT_COLORS with
a type keyed by LibraryId to catch typos/removed ids: change its declaration
from Record<string,string> to Partial<Record<LibraryId,string>> (or
Readonly<...> if immutability is desired), and import or reference the existing
LibraryId type so the compiler enforces allowed keys; keep existing keys/values
unchanged and ensure any missing keys fall back to current runtime defaults.
In `@src/server/og/generate.server.ts`:
- Around line 74-77: clampText currently slices to max-1 and appends an ellipsis
which can cut mid-word and, because of trimEnd(), may produce a string shorter
than max; update clampText to prefer truncation at the last whitespace before
the max boundary: in function clampText find the initial slice (text.slice(0,
max - 1)), trimEnd it, then if the original text length > max search for the
last space in that slice and, if found, slice to that space instead before
appending '…' so truncation lands on a word boundary while still ensuring the
result is ≤ max characters.
In `@src/server/og/template.tsx`:
- Around line 117-123: The hexToRgba function can produce NaN for non-6-digit
inputs; update hexToRgba to validate and normalize input: strip '#', if length
=== 3 expand shorthand (e.g. 'abc' -> 'aabbcc'), if length === 6 parse as
before, otherwise return a safe fallback (e.g. defaultColor or a hardcoded rgba
black/transparent) or throw/log an error; reference the hexToRgba function to
locate the fix and ensure callers keep the same signature (hexToRgba(hex:
string, alpha: number): string).
In `@src/utils/og.ts`:
- Around line 14-25: The ogImageUrl helper currently forwards raw options which
can produce arbitrarily long query strings; clamp the title and description
client-side to the same limits used by the server-side generator (e.g., the
MAX_* limits from generateOgPng in generate.server.ts) before building the URL
so the meta og:image query is bounded and CDN cache keys remain stable. Update
ogImageUrl to import or duplicate the MAX_TITLE/MAX_DESCRIPTION constants (or
call a shared clamp utility) and truncate options.title and options.description
to those lengths prior to creating URLSearchParams; reference the ogImageUrl
function and the generateOgPng/MAX constants in generate.server.ts to keep both
sides consistent.
In `@tests/smoke.ts`:
- Around line 60-66: Add a 404 smoke test to the OG tests by extending the
ogTests array (ImageTestCase) with a case that targets a non-existent library
path, e.g. { name: 'OG image · unknown library (404)', path:
'/api/og/not-a-library.png' }, and update the test runner assertion for that
case to expect response.status === 404 (instead of image content checks used for
happy paths) so the route handler's error branch is exercised.
- Around line 170-171: The shared counters passed and failed are reused for both
the HTML block and the OG block, making the intermediate "X passed, Y failed"
message misleading; update the test to use separate counters (e.g.,
passedHtml/failedHtml and passedOg/failedOg) or reset passed/failed before
starting the OG block so the printed totals reflect only that block's results;
locate and modify the variables passed and failed and the summary prints that
reference them near the HTML and OG test sections to ensure each block reports
its own counts.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 10f12b71-be03-4d11-9abc-04ae073406a3
⛔ Files ignored due to path filters (3)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yamlpublic/fonts/Inter-ExtraBold.ttfis excluded by!**/*.ttfpublic/fonts/Inter-Regular.ttfis excluded by!**/*.ttf
📒 Files selected for processing (35)
.gitignorenetlify.tomlpackage.jsonscripts/og-preview.tssrc/libraries/ai.tsxsrc/libraries/config.tsxsrc/libraries/db.tsxsrc/libraries/devtools.tsxsrc/libraries/form.tsxsrc/libraries/hotkeys.tsxsrc/libraries/libraries.tssrc/libraries/pacer.tsxsrc/libraries/query.tsxsrc/libraries/ranger.tsxsrc/libraries/router.tsxsrc/libraries/store.tsxsrc/libraries/table.tsxsrc/libraries/types.tssrc/libraries/virtual.tsxsrc/routeTree.gen.tssrc/routes/$libraryId/$version.docs.$.tsxsrc/routes/$libraryId/$version.docs.community-resources.tsxsrc/routes/$libraryId/$version.docs.framework.$framework.$.tsxsrc/routes/$libraryId/$version.docs.framework.$framework.examples.$.tsxsrc/routes/$libraryId/$version.index.tsxsrc/routes/$libraryId/route.tsxsrc/routes/-library-landing.tsxsrc/routes/api/og/$library[.png].tssrc/server/og/assets.server.tssrc/server/og/colors.tssrc/server/og/generate.server.tssrc/server/og/template.tsxsrc/utils/og.tstests/smoke.tsvite.config.ts
💤 Files with no reviewable changes (15)
- src/libraries/form.tsx
- src/libraries/table.tsx
- src/libraries/router.tsx
- src/libraries/db.tsx
- src/libraries/hotkeys.tsx
- src/libraries/virtual.tsx
- src/libraries/ranger.tsx
- src/libraries/query.tsx
- src/libraries/store.tsx
- src/libraries/types.ts
- src/libraries/devtools.tsx
- src/libraries/config.tsx
- src/libraries/ai.tsx
- src/libraries/pacer.tsx
- src/libraries/libraries.ts
Swap the two-stage satori (JSX→SVG) + resvg-js (SVG→PNG) pipeline for takumi's single-stage Rust renderer. Smaller PNGs (~22%) and faster cold start on the Netlify function. The island PNG moves from an inline base64 data URL to takumi's persistent-image cache, referenced by key. Route handler now returns the ImageResponse directly instead of wrapping a Buffer.
canonicalUrl always returns the production hostname, so on Netlify preview/branch deploys the og:image meta tag pointed at https://tanstack.com/api/og/<id>.png — which 404s if production hasn't shipped the endpoint yet, making validators report the image as invalid/unreachable. og:image now prefers DEPLOY_PRIME_URL → DEPLOY_URL → URL → SITE_URL on SSR, so each deploy emits a same-origin og:image URL. Production behavior is unchanged (DEPLOY_PRIME_URL === URL on production builds). Canonical link tags continue to use canonicalUrl and point to prod.
# Conflicts: # src/libraries/libraries.ts
- Inter-Regular registered with weight 400 (was 700) — matches the font file and pairs cleanly with Inter-ExtraBold at 800 so template fontWeight 500 resolves to the closer regular face. - Tighten LIBRARY_ACCENT_COLORS to Partial<Record<LibraryId, string>> so removing or renaming a library id is a TS error rather than a silent fallback to the default. getAccentColor takes LibraryId now. - Share MAX_OG_TITLE_LENGTH / MAX_OG_DESCRIPTION_LENGTH / clampOgText via src/utils/og-limits.ts and clamp client-side in ogImageUrl so the og:image URL stays bounded and CDN cache keys stay stable. - Hoist duplicated title/description strings in the examples route head() so seo() and ogImageUrl() can't drift. - Smoke tests: add a 404 assertion for unknown library ids, and report HTML / OG / Total counts separately so a mid-run "X passed" isn't misleading.

Summary
og:imageURL on every TanStack library landing and docs page with a package-themed PNG rendered per-request at/api/og/:library.png.textStyle.titleanddescriptionas query params — landing pages fall back to the library's name +tagline.ogImageGitHub-header URLs from every library definition.Visual
Each OG image:
TanStack(white) /[Package](library color) / doc title if present (library color) / description (library color, smaller).Run
pnpm exec tsx scripts/og-preview.tslocally to render 34 samples into.og-preview/(17 libraries × landing + docs variants).Notable implementation choices
Test plan
/api/og/ai.png,/api/og/query.png,/api/og/devtools.pngsuccessfully (cold start reads font + island bytes from the bundle).curl -sI https://<preview>/api/og/bogus-library.pngreturns HTTP 404 in production (dev-mode wraps 404 in HTML shell; production should pass through).Out of scope
Summary by CodeRabbit
New Features
Chores
Tests