Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
9f800dd
feat(void): add next/og-compatible ImageResponse in void/og
ogawa0071 Oct 16, 2026
2d901ca
test(void): type the Miniflare runtime in the void/og integration test
ogawa0071 Oct 16, 2026
9e2d6d1
fix(void): draw CJK and accented Latin text in void/og images
ogawa0071 Oct 16, 2026
bf10872
Cite upstream sources in void/og and isolate fonts per image
ogawa0071 Oct 18, 2026
f01c06f
Fix void/og review findings
ogawa0071 Oct 19, 2026
c87fc1f
Cover void/og's documented failure paths and fix the guide
ogawa0071 Oct 22, 2026
d5fa8e9
Keep Latin text on Geist and cache emoji in void/og
ogawa0071 Oct 27, 2026
0d0aae3
Fetch images once per void/og render and hash each font buffer once
ogawa0071 Oct 27, 2026
e1bedec
Show void/og in a .tsx route now that routes accept JSX
ogawa0071 Nov 3, 2026
443fc9c
Move void/og to takumi-js ~2.18.2
ogawa0071 Nov 3, 2026
9ffc276
Test the ~ range in void/og's Node.js check and note how to verify a fix
ogawa0071 Nov 3, 2026
aa979e1
Use measured CPU figures in the void/og guide
ogawa0071 Nov 3, 2026
6d1c6bd
fix(void): find void's package.json without assuming the module's depth
yangshun Nov 11, 2026
3b8663f
docs(og): note the renderer's Worker size on Cloudflare
yangshun Nov 11, 2026
753bbc5
fix(og): let lang choose the Chinese, Japanese or Korean font before …
yangshun Nov 11, 2026
5c77568
fix(og): drop only the fallback font files that fail to download
yangshun Nov 11, 2026
59b61df
fix(og): draw bold fallback text with the fonts' weight axis
yangshun Nov 11, 2026
de27bcf
docs(og): describe void/og on its own
yangshun Nov 11, 2026
49b35f9
chore(og): upgrade takumi-js to 2.20
yangshun Nov 12, 2026
d8218da
refactor(og): declare ImageResponseOptions instead of passing Takumi'…
yangshun Nov 12, 2026
b648869
docs(og): explain void/og behavior without citing other libraries
yangshun Nov 12, 2026
b4aeb79
fix(og): accept any font weight from 1 to 1000
yangshun Nov 12, 2026
376b1bd
fix(og): draw ・ and ー with the Japanese fallback family
yangshun Nov 12, 2026
4121679
refactor(og): move font handling into og-fonts.ts
yangshun Nov 12, 2026
d55e7e8
perf(og): ask Google Fonts for only the characters in the image
yangshun Nov 12, 2026
64d88ec
feat(og): add fallbackFonts to turn off Google Fonts downloads
yangshun Nov 12, 2026
47d2bfa
docs(og): note font memory growth in the Node.js, Bun and Deno section
yangshun Nov 12, 2026
70f1d83
fix(og): send one Cache-Control header in development and builds
yangshun Nov 12, 2026
192bd40
docs(og): trim the void/og notes in the package AGENTS.md
yangshun Nov 12, 2026
a07c95d
docs(og): describe the renderer's size without Cloudflare's limits
yangshun Nov 12, 2026
18388cb
fix(og): keep Han characters when Korean text also has Hangul
yangshun Nov 12, 2026
461ee03
docs(og): load the example font from Google Fonts
yangshun Nov 12, 2026
1876160
docs(og): say which fonts draw text and name the Noto fallback
yangshun Nov 12, 2026
af9934c
chore: add ImageResponse playground
kossnocorp Nov 12, 2026
5776825
chore: expose `renderImage`'s options type
kossnocorp Nov 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 28 additions & 3 deletions .github/scripts/packed-consumer.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,22 @@ async function run(command, args, cwd, env = environment) {
}
}

const packedImageRoute = `import { ImageResponse } from 'void/og';
export const GET = () => new ImageResponse('<div style="display:flex;width:100%;height:100%;background:#000;color:#fff">Packed</div>');
`;

/** Fetches the void/og route and checks for a 1200x630 PNG. */
async function assertPackedImage(url, failure) {
const response = await fetch(url);
assert.equal(response.status, 200, failure());
assert.equal(response.headers.get('content-type'), 'image/png', failure());
// A render error arrives after the status, as a failed body.
const bytes = new Uint8Array(await response.arrayBuffer());
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
assert.deepEqual([...bytes.subarray(1, 4)], [80, 78, 71], failure());
assert.deepEqual([view.getUint32(16), view.getUint32(20)], [1200, 630], failure());
}

const packages = [];
for (const entry of readdirSync(join(root, 'packages'), { withFileTypes: true })) {
if (!entry.isDirectory()) {
Expand Down Expand Up @@ -143,7 +159,8 @@ writeFileSync(
environment.npm_config_registry = registry.origin;
environment.NPM_CONFIG_REGISTRY = registry.origin;
environment.PNPM_CONFIG_REGISTRY = registry.origin;
const version = packages.find((pkg) => pkg.manifest.name === 'void').manifest.version;
const voidManifest = packages.find((pkg) => pkg.manifest.name === 'void').manifest;
const version = voidManifest.version;
const managers = process.env.VOID_PACKED_PM ? [process.env.VOID_PACKED_PM] : ['npm', 'pnpm'];
assert(managers.every((manager) => ['npm', 'pnpm'].includes(manager)));
const frameworks = process.env.VOID_PACKED_FRAMEWORK
Expand Down Expand Up @@ -329,6 +346,8 @@ try {
join(fixture, 'routes/api/packed-email.ts'),
`import { defineHandler } from 'void';\nimport { sendEmail } from 'void/email';\nexport const GET = defineHandler(async () => { return sendEmail({ from: 'packed@example.com', to: 'inbox@example.com', subject: 'Packed', text: 'ok' }); });\n`,
);
// void/og imports its renderer's Wasm, and React only when the app has it.
writeFileSync(join(fixture, 'routes/api/packed-og.ts'), packedImageRoute);
mkdirSync(join(fixture, 'public'), { recursive: true });
writeFileSync(join(fixture, 'public/packed.txt'), label);
const vite = join(
Expand Down Expand Up @@ -421,6 +440,7 @@ if (runtime.manifest.platformVersion !== ${JSON.stringify(version)}) throw new E
} else {
assert.equal(email.error?.code, 'BINDING_MISSING', emailBody);
}
await assertPackedImage(`${base}/api/packed-og`, task.failure);
if (full && framework === 'react') {
const markdown = await fetch(`${base}/markdown`);
assert.equal(markdown.status, 200, task.failure());
Expand Down Expand Up @@ -463,9 +483,11 @@ if (runtime.manifest.platformVersion !== ${JSON.stringify(version)}) throw new E
if (framework === 'react') {
// The Node target bundles the installed void into its build, so a ValidationError from
// void/handler is the class that page actions check, as in dev.
// void/og renders with the app's own takumi-js on Node.js, as its guide says.
const takumi = `takumi-js@${voidManifest.dependencies['takumi-js']}`;
await run(
manager,
manager === 'npm' ? ['install', '@hono/node-server'] : ['add', '@hono/node-server'],
[manager === 'npm' ? 'install' : 'add', '@hono/node-server', takumi],
fixture,
);
writeFileSync(
Expand All @@ -474,6 +496,8 @@ if (runtime.manifest.platformVersion !== ${JSON.stringify(version)}) throw new E
);
// KV and email are Cloudflare bindings.
rmSync(join(fixture, 'routes'), { recursive: true });
mkdirSync(join(fixture, 'routes/api'), { recursive: true });
writeFileSync(join(fixture, 'routes/api/packed-og.ts'), packedImageRoute);
writeFileSync(
join(fixture, 'pages/form.tsx'),
'export default function Form() {\n return <h1>Form</h1>;\n}\n',
Expand Down Expand Up @@ -507,12 +531,13 @@ if (runtime.manifest.platformVersion !== ${JSON.stringify(version)}) throw new E
const body = await response.text();
assert.equal(response.status, 200, `${body}\n${task.failure()}`);
assert(body.includes('Email already taken'), body);
await assertPackedImage(`${base}/api/packed-og`, task.failure);
} finally {
await task.stop();
}
}
console.log(
`Passed packed ${label} (${suite}): init, frozen install, ${useVitePlus ? 'Vite+ build/typecheck/test' : 'public Vite build/typecheck'}, assets, KV, email, runtime integrity${full ? ', dev/preview, hydration/navigation' : ', dev HTTP'}${framework === 'react' ? ', Node target' : ''}`,
`Passed packed ${label} (${suite}): init, frozen install, ${useVitePlus ? 'Vite+ build/typecheck/test' : 'public Vite build/typecheck'}, assets, KV, email, image, runtime integrity${full ? ', dev/preview, hydration/navigation' : ', dev HTTP'}${framework === 'react' ? ', Node target' : ''}`,
);
} catch (error) {
failures.push({ fixture: label, error: String(error.stack ?? error) });
Expand Down
1 change: 1 addition & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,7 @@ export default extendConfig(
},
{ text: 'Custom SSR', link: '/guide/ssr' },
{ text: 'SSG', link: '/guide/ssg' },
{ text: 'Open Graph Images', link: '/guide/og-images' },
],
},
{
Expand Down
196 changes: 196 additions & 0 deletions docs/guide/og-images.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
---
outline: deep
---

# Open Graph Images

Use `ImageResponse` from `void/og` to generate Open Graph and social preview images in a route handler. It draws JSX or HTML to a PNG with [Takumi](https://takumi.kane.tw/docs/), without a headless browser.

## Create an image route

Return an `ImageResponse` from a [`.tsx` route](./server-routing.md#jsx-handlers):

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

export const GET = defineHandler(async (c) => {
const post = await getPost(c.req.param('slug'));
if (!post) {
return c.notFound();
}
return new ImageResponse(
<div
style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
width: '100%',
height: '100%',
padding: 80,
background: '#0f172a',
color: 'white',
}}
>
<div style={{ fontSize: 72, fontWeight: 700 }}>{post.title}</div>
<div style={{ fontSize: 36, opacity: 0.7 }}>{post.author}</div>
</div>,
);
});
```

`GET /og/hello-world` now returns a 1200 × 630 PNG.

JSX works in React apps. In Vue, Svelte, and Solid apps, pass an HTML string instead, as shown in [Without React](#without-react). Elements also accept Tailwind classes in a `tw` prop, such as `<div tw="flex h-full w-full items-center justify-center bg-white">`.

### Without React

Pass an HTML string instead of JSX, and escape the values you insert:

```ts
// routes/og/[slug].ts
import { defineHandler } from 'void';
import { ImageResponse } from 'void/og';
import { getPost } from '../../src/posts';

const escapeHtml = (text: string) => text.replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`);

export const GET = defineHandler(async (c) => {
const post = await getPost(c.req.param('slug'));
if (!post) {
return c.notFound();
}
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

</div>`,
);
});
```

## Options

The second argument sets the image and the response:

| Option | Default | Description |
| --------------------------------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `width`, `height` | `1200`, `630` | Image size in pixels. |
| `fonts` | Geist | Fonts to draw text with. See [Fonts](#fonts). |
| `fallbackFonts` | `true` | Set to `false` to download no fonts from Google Fonts. See [Control font downloads](#control-font-downloads). |
| `emoji` | `'twemoji'` | Emoji style: `twemoji`, `blobmoji`, `noto`, `openmoji`, `fluent`, or `fluentFlat`. |
| `lang` | | Language of the text, such as `'ja'` or `'zh-TW'`, for Chinese characters. |
| `debug` | `false` | Draws a border around every box. |
| `images` | | Limits for remote images and emoji: `allowUrl`, `maxBytes`, `timeout`, and `throwOnError`. Set them when the markup includes request values. |
| `status`, `statusText`, `headers` | | The response status and headers. |

Responses have these headers unless you set them in `headers`:

```http
Content-Type: image/png
Cache-Control: public, max-age=0, must-revalidate
```

Clients revalidate the image on each use; [cache it at the edge](#cache-generated-images) so they get the cached copy.

Emoji and remote images are downloaded while the image renders. An emoji that the chosen style does not have, or an image that fails to download, fails the image unless you pass `images: { throwOnError: false }`, which leaves it out; `noto` has no flags.

## Fonts

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.

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.


Chinese characters are drawn in the Japanese style unless the `lang` option or the root element's `lang` attribute names the language, or the text has Korean around them. A `lang` wins over the text, so a Chinese title with a katakana word keeps the Chinese style:

```ts
return new ImageResponse(`<div lang="zh-TW" style="display: flex">${title}</div>`);
```

One style applies to all Chinese characters in an image, so an image cannot mix Japanese and Chinese styles through `lang` on inner elements.

To use your own fonts, pass `fonts`. An entry is either a font URL or an object with the font's data:

```ts
return new ImageResponse(postCard(post), {
fonts: [
'https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuFuYAZ9hiA.woff2',
{ name: 'Inter', data: interRegular, weight: 400, style: 'normal' },
],
});
```

A `weight` from 1 to 1000 makes the font draw only that weight. Leave it out for a variable font, so each element's `fontWeight` sets the weight, including values between the usual steps such as `550`.

Fonts can be TTF, OTF, or WOFF2 files; on Node.js and Bun, WOFF too. A font entry's `lang` is accepted but does not choose the font for a language; use the `lang` option or attribute for Chinese characters instead.

### Control font downloads

Each server keeps the fonts it downloads in memory until it restarts. Every image with new text downloads a small font made for that text, such as a 26 KB file for a Japanese title, so the memory of a long-running Node.js, Bun, or Deno server grows with the number of different titles it renders. On Cloudflare Workers, the downloads also count toward the [subrequest limit](https://developers.cloudflare.com/workers/platform/limits/#subrequests) and add latency to the first render.

To decide which fonts load and when, pass the fonts your images need in `fonts` and set `fallbackFonts: false`. Void then downloads no fonts, and characters that neither your fonts nor Geist can draw are drawn as boxes:

```ts
return new ImageResponse(postCard(post), {
fonts: [{ name: 'Noto Sans JP', data: notoSansJp }],
fallbackFonts: false,
});
```

A font passed as data loads once per server, however many images use it.

## Add the image to a page

Point an `og:image` tag at the route from your page's [`head()`](./pages-routing/head.md):

```ts
// pages/posts/[slug].server.ts
import { defineHead } from 'void';

export interface Props {
post: { title: string; slug: string };
}

export const head = defineHead<Props>((c, props) => {
return {
title: props.post.title,
meta: [
{ property: 'og:image', content: new URL(`/og/${props.post.slug}`, c.req.url).href },
{ name: 'twitter:card', content: 'summary_large_image' },
],
};
});
```

## Cache generated images

Each request renders the image again. A simple 1200 × 630 card takes around 10 ms of CPU time, one with gradients, bold text, or emoji 30 to 80 ms, and the first render in a new Worker instance more than 100 ms. [Workers Free allows 10 ms per request](../reference/config.md#worker) and fails requests from a Worker that keeps exceeding it, so on Workers Free, cache image routes with [revalidation](./edge/revalidation.md):

```json
{
"routing": {
"revalidate": {
"/og/*": 86400
}
}
}
```

Void never caches paths under `/api`, so serve images from another path, such as `/og/...`. The cache ignores query strings, so put values in the path, or list the query parameters in [`routing.revalidateQueryAllowlist`](../reference/config.md#routing-revalidatequeryallowlist).

## Worker size

On Cloudflare, importing `void/og` adds Takumi's WebAssembly renderer to your Worker: about 4.3 MB, or 1.9 MB compressed. A larger Worker takes longer to upload on each deploy and to start in a new instance. Apps that do not import `void/og` do not include the renderer.

## Node.js, Bun, and Deno

On the [Node.js, Bun, and Deno targets](../integrations/nodejs-bun-deno.md), add `takumi-js` to your app's `dependencies`, not `devDependencies`, so production installs include it:

```sh
npm install takumi-js@~2.20.0
```

If the build cannot find a version of `takumi-js` that Void supports, it fails and prints the command to install one.

The server keeps every font it downloads for text that Geist cannot draw until it restarts, so its memory grows with each image that has new text. If your images show many different titles in other scripts, pass your own fonts with `fallbackFonts: false`; see [Control font downloads](#control-font-downloads).
2 changes: 1 addition & 1 deletion docs/guide/pages-routing/head.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export const head = defineHead<Props>((c, props) => {
});
```

`head()` runs server-side only, after the loader resolves.
`head()` runs server-side only, after the loader resolves. To generate the image an `og:image` tag points to, see [Open Graph Images](../og-images.md).

## Site defaults

Expand Down
4 changes: 4 additions & 0 deletions docs/integrations/nodejs-bun-deno.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,10 @@ Supply server values through your host's environment and read them with `process

For browser code, use Vite's `import.meta.env.VITE_*` values. These are embedded at build time and must not contain secrets.

### Open Graph images

[`void/og`](../guide/og-images.md) needs `takumi-js` in your app's `dependencies`. See [Node.js, Bun, and Deno](../guide/og-images.md#node-js-bun-and-deno).

### No Cloudflare bindings

The following imports are **not available** with a non-CF target and produce a compile-time error:
Expand Down
1 change: 1 addition & 0 deletions docs/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ Import Void's app-facing APIs from these paths:
| `void/live` | `defineLiveStream`, SSE topic fanout runtime types. Server-only. |
| `void/live/client` | `connectLiveStream`, browser helper for one SSE connection plus POST subscribe/unsubscribe control. |
| `void/response` | `convertReturnValue` |
| `void/og` | `ImageResponse` and `ImageResponseOptions`. Renders JSX or HTML to a 1200 × 630 PNG `Response`. See [Open Graph Images](../guide/og-images.md). |
| `void/validator` | `runValidation`, `ValidatorSlots`, `HandlerInput` |
| `void/drizzle-zod` | Re-exports [`drizzle-zod`](https://orm.drizzle.team/docs/zod) for schema-derived Zod validators for Drizzle tables |
| `void/drizzle-valibot` | Re-exports [`drizzle-valibot`](https://orm.drizzle.team/docs/valibot) for schema-derived Valibot validators for Drizzle tables |
Expand Down
Loading