Repository navigation
feat(void): add ImageResponse for Open Graph images in void/og #688
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
9f800dd
2d901ca
9e2d6d1
bf10872
f01c06f
c87fc1f
d5fa8e9
0d0aae3
e1bedec
443fc9c
9ffc276
aa979e1
6d1c6bd
3b8663f
753bbc5
5c77568
59b61df
de27bcf
49b35f9
d8218da
b648869
b4aeb79
376b1bd
4121679
d55e7e8
64d88ec
47d2bfa
70f1d83
192bd40
a07c95d
18388cb
461ee03
1876160
af9934c
5776825
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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)} | ||
| </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. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think we better stick to VoidZero's Inter rather than Geist.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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). | ||
There was a problem hiding this comment.
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:
The good news is that we already have it in Hono: https://hono.dev/docs/helpers/html
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Great point