Skip to content

feat(void): add ImageResponse for Open Graph images in void/og - #688

Open
ogawa0071 wants to merge 35 commits into
voidzero-dev:mainfrom
ogawa0071:feat/og-image
Open

ogawa0071 wants to merge 35 commits into
voidzero-dev:mainfrom
ogawa0071:feat/og-image

Conversation

@ogawa0071

@ogawa0071 ogawa0071 commented Nov 3, 2026 •

Copy link
Copy Markdown

Closes #442

Summary

Adds void/og: an ImageResponse that renders JSX or an HTML string to a PNG from a route handler, for Open Graph and social preview images, on Cloudflare Workers and on the Node.js, Bun and Deno targets.

// routes/og/[slug].tsx
import { defineHandler } from 'void';
import { ImageResponse } from 'void/og';

export const GET = defineHandler((c) =>
  new ImageResponse(
    <div style={{ display: 'flex', fontSize: 72 }}>Hello {c.req.param('slug')}</div>,
  ),
);

Options

ImageResponseOptions is Void's own list, mapped to Takumi's render options one by one, so a Takumi upgrade does not change the API:

  • width, height, debug, fonts, emoji (twemoji, blobmoji, noto, openmoji, fluent, fluentFlat), status, statusText, headers.
  • lang: chooses the style of Chinese characters, as the root element's lang attribute does.
  • images: allowUrl, maxBytes, timeout and throwOnError for the images and emoji in the markup. allowUrl is how an app keeps request values in image URLs from reaching internal addresses on the Node.js target.
  • A fonts entry is { name, data, weight?, style?, lang? } with the font's bytes, or a font file's URL. weight is any number from 1 to 1000; leaving it out lets a variable font follow each element's fontWeight.
  • fallbackFonts: false turns off the Google Fonts downloads described below, for apps that pass their own fonts.

Takumi's other options (format, devicePixelRatio, css, renderer, module and so on) are not accepted.

Why Takumi

Takumi (takumi-js) ships a Wasm build with a workerd export condition, which the Cloudflare Vite plugin loads as a compiled module in dev and builds, plus a native addon for Node.js and Bun. It takes JSX or HTML and draws straight to pixels, so void/og stays a thin wrapper.

Warm render times for 1200 × 630 cards in workerd (Miniflare, median):

Card Takumi Wasm
Title and author 10 ms
Gradient, shadow, tags 31 ms
~1,300 characters of text 13 ms

The first request in a new isolate takes about 110 ms. With the native addon on Node.js, the same cards take 3–10 ms.

What the wrapper adds

  • Defaults: a 1200 × 630 PNG, Content-Type, and debug. Cache-Control is public, max-age=0, must-revalidate. The guide shows how to cache image routes with routing.revalidate.
  • Fallback fonts: Takumi's built-in Geist covers Latin, so for text it cannot draw (Japanese, Chinese, Korean, Thai, Arabic, Cyrillic, symbols and more) the wrapper loads the matching Noto family, or Geist's own Latin Extended and Cyrillic subsets, from Google Fonts.
    • Each family is requested with text= and only the characters the image uses, so Google returns one small file. On a deployed Worker, a 45-character Japanese title takes 2 requests and a 26 KB file, against 21 requests and 480 KB for the per-range files. Text too long for one request (about 650 CJK characters) falls back to the per-range files.
    • A server keeps each downloaded font until it restarts, about 37 KB per new title in a Node.js test of 800 titles. The guide describes this, and fallbackFonts: false with the app's own fonts avoids it.
    • Han text is drawn as Japanese unless lang names the language or the text has Hangul. An explicit lang wins over kana, so a Traditional Chinese title with a katakana word stays Chinese.
    • Variable families are requested with their full weight range, so fontWeight: 700 draws real bold rather than a thickened regular.
    • Latin text stays on the built-in Geist. A fallback file that fails to download is left out on its own; the others still render.
  • Font handling lives in packages/void/src/runtime/og-fonts.ts.
  • Each image renders with only its own fonts (Takumi otherwise uses every font registered in the process), fonts passed as bytes register once, and emoji images are cached per isolate.

Node.js, Bun and Deno

Takumi loads its renderer from files next to its own code, which a bundle cannot carry. On these targets the build fails with the install command when the app has no supported takumi-js in its own dependencies; the dev server only warns.

The app installs takumi-js because the production install runs on the server, so it fetches the native binary for that OS and CPU. Two alternatives were considered and left out:

  • Rendering with Takumi's Wasm build on Node.js needs no install and gives identical PNGs, but renders 2.5–3× slower (a rich card: 20 ms against 6.5 ms) and uses about 70 MB more memory.
  • Copying takumi-js and the build machine's binary into dist/ssr adds about 12 MB and breaks when the build runs on another platform than the server, such as macOS to a Linux container.

The build accepts any takumi-js in Void's ~2.20.0 range, so raising that range later asks Node.js apps to update theirs.

Workers Free

Apps that don't import void/og bundle nothing extra (covered by a test). Takumi's Wasm adds about 4.3 MB (1.9 MB gzipped) to the Worker. A simple card takes around 10 ms of CPU, richer ones 30–80 ms, and the first render in a new isolate more than 100 ms, so the guide tells Workers Free apps to cache image routes with revalidation.

takumi-js version

takumi-js is on ~2.20.0. 2.19.0 loads only the first font whose unicode-range covers a character and draws the whole word as .notdef boxes when that font lacks the glyph: with Japanese in the image, "Łódź" renders as boxes. 2.20.0 fixes this: Japanese next to Polish, symbols, mixed scripts, bold Japanese and twemoji render pixel-identical to 2.18.2 with both the native and Wasm backends, and the built-in Geist draws the same code points. The build rejects 2.19.x. packages/void/AGENTS.md notes how to check a version before raising the range.

Docs

  • New guide: docs/guide/og-images.md, with options, fonts, controlling font downloads, caching, Worker size, and the Node.js, Bun and Deno setup.
  • void/og in the API reference, a link from the head() guide, a note in the Node.js, Bun and Deno integration guide, and a task row in the void skill.

Tests

  • packages/void/test/unit/og.test.ts (34 tests): defaults and headers, JSX, font weights, options outside ImageResponseOptions not reaching Takumi, fallback-font selection and text= requests (including Han text next to Hangul), the long-text fallback, fallbackFonts: false, weights and subsetting, lang, per-image font isolation, partial fallback download failures, emoji, images.throwOnError, and the Node.js build check.
  • packages/void/test/integration/og-image.test.ts (6 tests): the dev server in workerd, the built Worker in Miniflare (one CompiledWasm module), an app without React, an app without void/og (no Wasm in its bundle), and the Node.js target, which renders with the app's takumi-js and fails the build without it.
  • .github/scripts/packed-consumer.mjs renders an image route from the packed tarballs in dev and preview, and on the Node.js target.

🤖 Generated with Claude Code

@ogawa0071
ogawa0071 requested a review from a team as a code owner November 3, 2026 14:44
@socket-security

socket-security Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedtakumi-js@​2.20.01001009696100

View full report

@yangshun yangshun self-assigned this Nov 11, 2026
@yangshun yangshun changed the title feat(void): add next/og-compatible ImageResponse in void/og feat(void): add ImageResponse for Open Graph images in void/og Nov 12, 2026
Comment thread docs/guide/og-images.md Outdated
Comment on lines +180 to +182
## Worker size

On Cloudflare, importing `void/og` adds Takumi's WebAssembly renderer to your Worker: about 4.3 MB, or 1.9 MB compressed. Cloudflare limits a Worker to 3 MB compressed on Workers Free and 10 MB on Workers Paid (see [Worker size](https://developers.cloudflare.com/workers/platform/limits/#worker-size)), so on Workers Free the rest of your app has about 1.1 MB. Apps that do not import `void/og` do not include the renderer.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cloudflare changed these limits on September 4, 2026: the 3 MB (Free) and 10 MB (Paid) check on the compressed bundle was removed, and Workers Free and Workers Paid now both allow 64 MiB, measured uncompressed (changelog, Worker size). The limits page now says: “There is no compressed size limit. Only the uncompressed bundle size counts.”
So void/og adds about 4 MiB, which fits on either plan with plenty of room.

@yangshun yangshun Nov 12, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the tip. I forgot about that and am glad they're much higher now. I'm glad the size concern is gone. Will update it!

@yangshun yangshun left a comment •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ogawa0071 Thanks for the PR! I've pushed some follow-up commits on top of yours. Summary below, please have a look and lemme know if there's anything amiss.

At the bottom there are notes for reviewers (cc @cpojer):

Changes since the original submission

API

  • ImageResponseOptions is now Void's own list, mapped to Takumi's options one by one: width, height, debug, fonts, fallbackFonts, emoji, lang, images (allowUrl, maxBytes, timeout, throwOnError), plus status, statusText and headers. Takumi's other options (format, devicePixelRatio, css, renderer, module, fontFamilies, …) are no longer accepted, so a Takumi upgrade can't change Void's API, and backend options can't bypass the per-image font handling.
  • A fonts entry is a font file's URL or { name, data, weight?, style?, lang? }. weight accepts any value from 1 to 1000; leaving it out lets a variable font follow each element's fontWeight (e.g. 550).
  • New fallbackFonts: false turns off the Google Fonts downloads, for apps that pass their own fonts.
  • Cache-Control is always public, max-age=0, must-revalidate. The dev-only no-cache, no-store was dropped: with max-age=0 and no validator, clients refetch anyway.

Fallback fonts

  • Fonts are requested from Google Fonts with text= and only the characters in the image, so Google returns one small file. On a deployed Worker, a 45-character Japanese title went from 21 requests / 480 KB to 2 requests / 26 KB. Text too long for one request falls back to the per-range files.
  • An explicit lang now chooses the Chinese, Japanese or Korean style before kana or Hangul in the text, so a Traditional Chinese title with a katakana word stays Chinese.
  • ・ and ー are drawn with the Japanese family rather than a separate request.
  • Han text next to Hangul is drawn: both scripts went to Noto Sans KR, and the Hangul request replaced the Han one, so mixed Chinese/Japanese/Korean text drew boxes.
  • Variable families are requested with their full weight range, so fontWeight: 700 draws real bold rather than a thickened 400.
  • When one fallback file fails to download, only that file is dropped; the others still render.
  • Font handling moved into packages/void/src/runtime/og-fonts.ts.

Dependencies and build

  • Upgraded to takumi-js ~2.20.0, which fixes 2.19.0's fallback regression ("Łódź" next to Japanese drew as boxes). 2.20.0 renders pixel-identical to 2.18.2 on both backends.
  • The Node.js build check finds void's package.json without assuming the module's depth.

Docs

  • The guide describes void/og on its own, with no comparisons to or migration from other libraries.
  • New "Control font downloads" section: memory growth on long-running Node.js, Bun and Deno servers, subrequests and first-render latency on Workers, and fallbackFonts: false with the app's own fonts.
  • The Fonts section says your fonts come before Geist and names Noto as the fallback. The font example uses a Google Fonts URL.
  • The Worker size section states the renderer's cost (about 4.3 MB, 1.9 MB compressed).
  • Shorter void/og notes in packages/void/AGENTS.md.

Tests: 34 unit tests and 6 integration tests, covering the cases above.

Live demo with Latin, Japanese, Chinese, Korean, per-lang Han and weight cards: https://void-og-test.yangshun.workers.dev

Reviewer notes

  1. CPU time on Workers Free. A simple card takes ~10 ms of CPU, richer ones 30–80 ms, and the first render in a new isolate >100 ms, against Free's 10 ms per request. The guide tells Free users to cache image routes with routing.revalidate. These numbers are from Miniflare; CPU time on a deployed Worker hasn't been measured.
  2. Downloads while rendering. By default, text Geist can't draw fetches a font from Google Fonts, and emoji come from jsDelivr. Each download counts as a subrequest and slows the first render; a failed font draws boxes rather than failing the image. fallbackFonts: false opts out. Google's text= parameter has no documented length limit; Void falls back to the per-range files above 6,000 encoded characters, below where it was measured to stop working.
  3. Memory on Node.js, Bun and Deno. Each new title keeps ~37 KB of font data until restart, unbounded. Takumi's render() uses a default renderer without fontCacheMaxBytes. A follow-up could create Void's own renderer with a cap. Documented for now.
  4. takumi-js install on Node.js targets. Apps must list takumi-js in dependencies, because the native addon can't be bundled and the production install fetches the right binary for the server. Rendering with the Wasm build on Node.js instead was measured at 2.5–3× slower with ~70 MB more memory. Raising Void's ~2.20.0 range later will ask Node.js apps to update theirs (the build error names the command). I couldn't find a better way around this. I think this is fine for now and we can revisit this.
  5. Remote images. Markup can fetch remote images. Apps that put request values in image URLs should set images.allowUrl and maxBytes. Worth a security look.
  6. Known Takumi behaviors, documented or noted rather than worked around:
    • One Chinese style applies per image, chosen by lang.
    • Geist is built into Takumi and stays as the last fallback.
    • An element whose font-family names a font the image didn't pass can draw with it if an earlier image on the same server registered it. Plain text without font-family isn't affected.
  7. API narrowed from the original. The original passed Takumi's options through, including other output formats (JPEG, WebP, ICO). Those are gone. Happy to discuss if any were important.

@cpojer
cpojer requested a review from kossnocorp November 12, 2026 06:54
@cpojer

cpojer commented Nov 12, 2026

Copy link
Copy Markdown
Member

@kossnocorp could you review this PR and merge it when it's ready?

@kossnocorp

Copy link
Copy Markdown
Contributor

@cpojer on it

@kossnocorp

Copy link
Copy Markdown
Contributor

@yangshun @ogawa0071 heads up, I updated the branch via GitHub, and it force-pushed the branch 🫠 Forgot it does that. Be careful if want to pull.

@yangshun

Copy link
Copy Markdown
Collaborator

@kossnocorp No problem, I won't be pushing anything for now. You can make any changes you want.

ogawa0071 and others added 17 commits November 12, 2026 16:02
Add `void/og`, a thin layer over Takumi (`takumi-js`) that renders JSX
or an HTML string to an image `Response` with the same arguments as
`next/og`. On workerd, Takumi's Wasm loads through the Cloudflare
plugin's compiled-module imports in dev and builds; Node.js and Bun use
Takumi's native renderer.

The wrapper adds what `next/og` callers expect: a 1200x630 default
size, the `cache-control` header (no-store during development), `debug`,
and Noto fallback fonts from Google Fonts for scripts the built-in
Geist cannot draw. Latin text stays on Geist, and a failed font
download renders without that font instead of failing the image.

On the Node.js, Bun and Deno targets the build fails with a clear
message when the app cannot resolve `takumi-js` itself, instead of
bundling the native addon so every render fails at runtime.

Apps that do not import `void/og` bundle nothing extra.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016XQ1t7Wv2KEzUhSApX7CWT
Miniflare types its own Response without Cloudflare's cf property, which
failed the package typecheck.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016XQ1t7Wv2KEzUhSApX7CWT
Google Fonts puts ASCII into the CJK subsets that hold the most common
characters, such as 日, 中 and 하. void/og dropped every subset that
covered "A" to keep Latin text on the default font, so those characters
rendered as missing glyphs. Skip only each family's Latin subset, and
take Latin-1 out of the others.

The built-in font also lacks Latin Extended-A and -B, so Polish, Czech,
Turkish and similar text now loads Noto Sans as well.

Fold the deprecated `stylesheets` option into `css` when the element
brings its own styles, since takumi rejects both together.

On the Node.js, Bun and Deno targets, the build now also fails when the
app's takumi-js is outside the range void declares, and the error names
the version to install. The docs give realistic CPU times, cover Deno,
and say JSX works in React apps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016XQ1t7Wv2KEzUhSApX7CWT
- Comment where each behavior comes from in @vercel/og 1.0.3, Satori
  0.33.5 and Next.js 16.4.2, and where void/og differs.
- Always pass Takumi a font list. Without one it used every font
  registered in the process, so an earlier image's fallback fonts
  changed the glyphs of later Latin-only images.
- Check the takumi-js version on Node.js targets only for void's own
  imports, so an app can use another takumi-js version itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016XQ1t7Wv2KEzUhSApX7CWT
- Load void under Bun again: read void's package.json by path, not
  `findPackageJSON`, which Bun's `node:module` lacks.
- Check the app's takumi-js for void's own imports only, following
  symlinks, and warn in the dev server instead of failing the build.
- Draw Han text as Japanese like next/og unless kana, Hangul or `lang`
  say otherwise; add Traditional Chinese, math and symbol fallbacks.
- Register fallback fonts under private names, so an app's own copy of
  the family does not change later images.
- Keep only the fallback subsets the text uses, extract emoji before
  choosing fonts, and fetch images once when the render runs twice.
- Keep the default size for `width: undefined`, accept Satori's font
  `lang`, fold `css` with `stylesheets`, and leave render errors to the
  runtime's report of the failed body.
- Docs: Workers Free CPU numbers, WOFF2 only, Next.js 16 cache header.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016XQ1t7Wv2KEzUhSApX7CWT
- Integration: the Node target build fails without takumi-js and prints
  the install command; a Cloudflare app without React renders HTML.
- Unit: emoji render as images without a font, a font's `lang` is
  dropped before takumi sees it, and no case reaches the network. The
  Google Fonts stubs answer 404, which takumi does not retry.
- Guide: `from-font` and `ico` in the options table, the root-only
  `lang` attribute, the Props type in the head() example, and that
  passing `fonts` does not stop the Noto download.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016XQ1t7Wv2KEzUhSApX7CWT
- A Google Fonts face without a unicode-range, such as Noto Sans Math,
  covered every character and drew the Latin text of any image with a
  math symbol. Treat it as a whole font above Latin-1.
- Detect fallback scripts only among characters the built-in Geist
  lacks, so `C++`, `©` or typographic quotes make no request, and µ
  gets a font. Accented Latin and Cyrillic come from Geist's own Google
  Fonts subsets; `Noto Sans Symbols` and `Noto Sans` cover number forms,
  currency, Greek and combining marks, as @vercel/og's `unknown` does.
- Request each family's CSS separately so takumi's cache serves it, and
  forward `images.fetch` and `images.timeout` to the font downloads.
- Key a font given as bytes by its content: Takumi keeps every
  registered font, so one read per request would grow without bound.
- Keep emoji images for the life of the isolate, as @vercel/og does.
- Send `public, max-age=0, must-revalidate` outside development, as
  Next.js 16 does: a failed render is still a complete response, which
  a year-long cache would keep. Log the failure again.
- Drop Takumi's deprecated `stylesheets` from the public options, and
  read package manifests through installed-packages.ts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016XQ1t7Wv2KEzUhSApX7CWT
- The retry without a failed fallback font fetched remote images again:
  share one image cache between both attempts.
- Hash a font buffer once per buffer, not per request, so a font kept at
  module scope costs no CPU.
- Count the combining marks and variation selectors the built-in Geist
  draws, so NFD text asks for no font.
- Guide: WOFF loads on Node.js and Bun.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016XQ1t7Wv2KEzUhSApX7CWT
Main added .tsx and .jsx route files (voidzero-dev#561), so the guide no longer
needs a separate module for the image's JSX: its example is one
`routes/og/[slug].tsx` route, and the next/og migration note moves
`app/api/og/route.tsx` to a route outside /api so it can be cached.
The integration test renders its JSX card from a .tsx route in dev and
in the built Worker. The guide says "local development" rather than
naming a dev command, as main's docs now do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016XQ1t7Wv2KEzUhSApX7CWT
void declared takumi-js ^2.16.2, so a fresh install already resolved
2.19.0, which the repository never tested. 2.19.0 loads only the first
font whose unicode-range covers a character and draws the whole word as
boxes when that font lacks the glyph: with Japanese in the image,
Noto Sans JP's latin-ext subset claims Ł and "Łódź" renders as four
boxes, and Noto Sans Symbols claims ❤ without having it. 2.16.2, 2.17.1
and 2.18.2 render both correctly.

Test and ship 2.18.2, the newest release that does, and keep fresh
installs on it with ~2.18.2. The Node.js build check reads ~ ranges and
rejects 2.19.0, and the guide's install command names the range.

2.18.1 added images.throwOnError: the guide now says an emoji the chosen
style lacks, or an image that fails to download, fails the image unless
it is false, and a unit test covers both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016XQ1t7Wv2KEzUhSApX7CWT
The guard tests now reject a patch below the minimum and a prerelease
inside the ~2.18 window, and accept a later patch. The guide says what
`throwOnError: false` does, and the maintainer note records that
reordering the fallback families or dropping unicode-range does not
avoid takumi-js 2.19.0's bug, and that a fix must be checked in a fresh
process.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016XQ1t7Wv2KEzUhSApX7CWT
Measured on V8 with takumi-js 2.18.2's Wasm build, a simple card takes
6–17 ms of CPU rather than under 10 ms, a richer one 31–81 ms, and the
first render 120–150 ms. "Under 10 ms" suggested a simple card fits the
Workers Free limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016XQ1t7Wv2KEzUhSApX7CWT
The void/og Node.js check read `../package.json` relative to the plugin
module, which only holds while the bundler emits it one directory below
the package root. Walk up to the nearest package.json named `void`, and
do it lazily so a lookup failure cannot break loading the plugin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
void/og adds Takumi's Wasm renderer, about 1.7 MB compressed, to the
Worker. Say how that fits Workers Free's 3 MB compressed limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…kana

Any kana in an image chose Noto Sans JP before `lang` was read, so a
Traditional Chinese title with a katakana loanword was drawn in the
Japanese style. An explicit `lang` now decides first; kana and Hangul
decide only without one. Google's Noto Sans SC, TC and KR include kana,
so the kana still draw. The guide notes that one style covers all Han
text in an image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When any fallback font file failed, the image was rendered again with no
fallback fonts at all, so a missing symbol font also dropped the
Japanese one. Each image now records the files that fail, warns once per
file, and retries without only those.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fallback fonts were requested from Google Fonts at weight 400 only. For
variable families (Noto Sans JP, KR, SC, TC, the Indic, Thai, Arabic and
Hebrew families, Noto Sans, Symbols and Geist) Takumi then pinned the
face to 400, so bold Chinese, Japanese or Korean text was only
thickened and 700 looked the same as 900. Request those families as
100..900, which Google serves as a weight range that Takumi leaves on
the font's axis. Noto Sans Math and Noto Sans Symbols 2 have no weight
axis and reject a range, so they stay at 400.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
yangshun and others added 16 commits November 12, 2026 16:02
Drop the comparisons with other libraries from the Open Graph guide and
the API reference, and the migration section. Keep its note that a font
entry's `lang` does not choose the font, under Fonts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2.20.0 fixes the 2.19.0 font fallback regression: Japanese next to "Łódź",
symbols, mixed scripts, bold Japanese and twemoji render pixel-identical to
2.18.2 with both the native and Wasm backends, and the built-in Geist draws the
same code points. The Wasm grows from 1.7 MB to 1.9 MB compressed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s through

ImageResponseOptions now lists width, height, debug, fonts, emoji, lang,
images (allowUrl, maxBytes, timeout, throwOnError) and the ResponseInit
fields, and og.ts maps each one to Takumi's render options. A Takumi
upgrade no longer changes the API, and backend options such as renderer,
module and fontFamilies no longer bypass the per-image font handling.

A fonts entry is a font file's URL or { name, data, weight, style, lang }.
format, devicePixelRatio, css, emoji: 'from-font' and Takumi's other
options are no longer accepted, and options outside the list do not reach
Takumi at runtime.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A font's weight fixes the weight it draws, so a variable font set to 550
or a static Book (350) face needs a weight between the 100 steps. Leaving
it out keeps a variable font free for each element's fontWeight.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Unicode files these marks under the Common script, so they matched no
script's family and fell through to Noto Sans, which cost almost every
Japanese image an extra Google Fonts request. Match by script extension.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
og.ts keeps the response, its options and the render; og-fonts.ts holds
the font type, keying of the app's fonts and the Google Fonts fallbacks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With a `text` query, Google answers with one file of just those
characters and the whole weight axis. A 45-character Japanese title now
takes 2 requests and one 26 KB file instead of 21 requests and 480 KB.
Text too long for one query still asks for the range files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each image with new text downloads a small font, and a server keeps it
until it restarts. The guide now says so, and shows how to pass the
fonts an app needs with fallbackFonts: false to download none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With max-age=0 and no validator, clients refetch the image on each use,
so the dev server needs no `no-cache, no-store` of its own. This drops
the runtime check for development, which read a flag that is also true
in builds made with NODE_ENV=development.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Keep what maintainers act on: the declared options, the Node.js, Bun and
Deno install check, and the Takumi upgrade steps. The font and Wasm
details already live in the source comments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cloudflare now limits a Worker to 64 MiB uncompressed on every plan and
has no compressed limit, so the guide no longer counts down from 3 MB.
It keeps what the renderer costs: 4.3 MB more to upload and to start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Text with Hangul draws Han characters with Noto Sans KR, so both scripts
asked for the same family, and the Hangul request replaced the Han one.
"日本語・中文・한국어" drew its Han characters, and the numbers beside them,
as boxes. Ask the family for both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@kossnocorp kossnocorp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only critical issue is accepting HTML string.

I would still rename the module to void/image, but I'm not sure how to handle default image sizes. I would remove them in this PR and add OgImageResponse (imported from void/og) with presets like: "standard" | "x-large" | "x-small" | "linkedin".

Using xxHash would be a bit faster that SHA but I don't think it matters.

Comment thread docs/guide/og-images.md
}
return new ImageResponse(
`<div style="display: flex; width: 100%; height: 100%; padding: 80px; font-size: 72px">
${escapeHtml(post.title)}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should be very careful recommending this approach. It is way too easy to forget escaping and introduce a vulnerability.

I think it is better to disallow passing strings and enforce secure code practices via literal template that would escape by default:

export const GET = defineHandler(async (c) => {
  const post = await getPost(c.req.param('slug'));
  if (!post) {
    return c.notFound();
  }
  return new ImageResponse(
    html`<div style="display: flex; width: 100%; height: 100%; padding: 80px; font-size: 72px">
      ${post.title}
    </div>`,
  );
});

The good news is that we already have it in Hono: https://hono.dev/docs/helpers/html

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great point

Comment thread docs/guide/og-images.md

Text uses Geist unless you pass `fonts`. Your fonts draw every character they have, and Geist draws only the characters they lack.

When the text includes characters that Geist cannot draw, such as Japanese, Chinese, Korean, Thai, Arabic, Hebrew, Cyrillic, Greek, accented letters like Ł and ş, or symbols like → and ★, Void downloads a font that has them from Google Fonts while rendering: the matching [Noto](https://fonts.google.com/noto) font, such as Noto Sans JP for Japanese, or Geist's own Cyrillic and accented letters. It asks for only the characters the image uses, whether or not `fonts` covers them. If the download fails, those characters are drawn as boxes.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we better stick to VoidZero's Inter rather than Geist.

Comment thread docs/guide/og-images.md

Text uses Geist unless you pass `fonts`. Your fonts draw every character they have, and Geist draws only the characters they lack.

When the text includes characters that Geist cannot draw, such as Japanese, Chinese, Korean, Thai, Arabic, Hebrew, Cyrillic, Greek, accented letters like Ł and ş, or symbols like → and ★, Void downloads a font that has them from Google Fonts while rendering: the matching [Noto](https://fonts.google.com/noto) font, such as Noto Sans JP for Japanese, or Geist's own Cyrillic and accented letters. It asks for only the characters the image uses, whether or not `fonts` covers them. If the download fails, those characters are drawn as boxes.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we better stick to the default VoidZero's Inter rather than Geist. It also has support for Cyrillic and Greek scripts: https://rsms.me/inter/#languages

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I prefer to use Inter as well, but didn't want to bundle an additional font. Takumi bundles Geist and there's no way to remove that. Considering Inter and Geist are similar enough, I chose to just stick with Geist.

let hash = fontHashes.get(data);
if (!hash) {
hash = crypto.subtle
.digest('SHA-256', data instanceof ArrayBuffer ? data : new Uint8Array(data))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using xxHash would be faster than SHA. Not critical but I would use https://www.npmjs.com/package/@node-rs/xxhash

@Brooooooklyn WDYT?

responseHeaders.set('content-type', 'image/png');
}
if (!responseHeaders.has('cache-control')) {
responseHeaders.set('cache-control', 'public, max-age=0, must-revalidate');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Curious what is the reasoning? I feel like it's safe to use the standard cache time that is applied by X/Facebook and others. Shouldn't it be optimized for performance out of the box, or there are other considerations?

@yangshun yangshun Nov 12, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. For many years Next.js was over-aggressive about caching and the community found it confusing, in Next 15 they reverted to no cache by default.

With the current way, there doesn't seem to be an easy way to invalidate the cached responses, and I wanted users to explicitly set a cache duration that suits the nature of their app / frequency of updates.

On hindsight maybe I was being too conservative and a standard/longer default would be better.

@kossnocorp

Copy link
Copy Markdown
Contributor

...and I would definitely use Inter instead of Geist.

@cpojer

cpojer commented Nov 12, 2026

Copy link
Copy Markdown
Member

I haven't spent time looking at the API yet, but I see two issues in the implementation:

  • It looks like Takumi's default font renderer retains fonts forever which leaks memory outside of workers. Can you bound fontCacheMaxBytes and make sure that font subsets are evicted in some form?
  • We should escape interpolated values by default to avoid accidentally injecting image tags or issuing requests. We should probably do something similar to Hono's html tag instead of accepting raw strings.

This branch has not been deployed

No deployments
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.

next/og compatible function built on Satori

4 participants