@vercel/ogが不便だったのでChakra UIで書いた普通のページコンポーネントを動的OGP画像として使えるようにした
動機
Vercel上で動かしているNext.jsなどのReactアプリケーションにおいてOGP画像を動的に生成したい場合、現代では @vercel/og を利用するのが一般的かと思います。
しかし、@vercel/og
はJSXでOGP画像をデザインできるとは言うものの、内部的に使用されている satori の制約を受けるため、Chakra UIなどのUIフレームワークを使うことはできず、既存のコンポーネントを流用できません。
また、デフォルトでは全角スペースや全角記号などをレンダリングできないため、Google Fontsなどから適当な日本語フォントをダウンロードして、Edge Functionにデプロイできるようサブセット化してサイズを削減する、というとても微妙なワークアラウンドも必要です。
さらに、原因はよく理解していませんが、@vercel/og
では /public
ディレクトリ配下のプロジェクトローカルの画像ファイルを出力できないようで、これも一旦画像ファイルをメモリに読み込んでdataURLに変換するといったワークアラウンドが必要です。
参考:@vercel/og を使ってブログに動的 og:image 生成を実装した | stin's blog
#画像の埋め込みに工夫が必要
この辺りが辛すぎたので、今回とあるプロダクトの動的OGP対応を行うにあたって、@vercel/og
は使わずに、普通のページコンポーネントとしてデザインしたHTMLをPlaywrightでスクリーンショットすることによって画像に変換してOGP画像として配信する、という方法をとりました。
実際に生成されたOGP画像の例は以下です。
(実際のサイトの当該ページにリンクしてあります。面白いサイトなのでよかったら覗いてみてください😇)
環境
- Next.js 12
- Vercel
やったこと
1. ページコンポーネントを作って、API RoutesのEdge FunctionからそのページをPlaywrightでスクショ
まず、/pages/og/posts/[id].tsx
といったファイルに、普通にページコンポーネントを作りました。これは見てのとおり画像ではなくHTMLです。
次に、/pages/api/og.ts
といったファイルで、以下のようなEdge Functionを作りました。
import type {NextApiRequest, NextApiResponse} from 'next'
import * as playwright from 'playwright-aws-lambda'
const origin = `${process.env.SITE_URL}`
const swr = 86400 * 31
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
const username = req.query.username
const viewport = {width: 1200, height: 630}
const browser = await playwright.launchChromium()
const page = await browser.newPage({viewport})
try {
const response = postId
? await page.goto(`${origin}/og/posts/${postId}`)
: undefined
if (!response?.ok()) throw new Error()
const image = await page.screenshot({type: 'png'})
await browser.close()
res.setHeader(
'Cache-Control',
`max-age=0, s-maxage=0, stale-while-revalidate=${swr}`,
)
res.setHeader('Content-Type', 'image/png')
res.end(image)
} catch (error) {
await page.goto(`${origin}/images/og.png`)
const image = await page.screenshot({type: 'png'})
await browser.close()
res.setHeader('Content-Type', 'image/png')
res.end(image)
}
}
前提として
$ npm i -S playwright-core playwright-aws-lambda
が必要です。
基本的な戦略は
Vercel + Next.js + PlaywrightでOGP画像を自動生成する
こちらの記事を参考にしました。
@vercel/og
登場以前の記事であり、この記事の内容自体はすでに 著者様ご自身によってDeprecatedと宣言されています のでご注意ください。
上記の記事ではEdge Function上で手動で組み立てたReactDOMをHTML文字列に変換してPlaywrightに食わせるという方法がとられていますが、これだと @vercel/og
を使うのと同様に様々な制約を受けるので、そうではなく前述のページコンポーネントの画面を物理的にリクエストして読み込み、それをスクリーンショットすることで画像化するという方法をとりました。
なお、
const swr = 86400 * 31
res.setHeader(
'Cache-Control',
`max-age=0, s-maxage=0, stale-while-revalidate=${swr}`,
)
この部分は、Vercel Edge Cacheを活用するためのコードです。生成したOGP画像は最大31日間キャッシュしつつ、キャッシュが利用された場合は裏でキャッシュを再生成しておく(stale-while-revalidate)という戦略をとっています。
Vercel + Next.js 12 での Vercel Edge Cache については こちらのスクラップ もご参照ください。
キャッシュする期間を長めにとっているので、投稿が編集されたらその場でOGP画像のURLをフェッチしてキャッシュを再生成するといった対応が別途必要になるかと思います。
また、
} catch (error) {
await page.goto(`${origin}/images/og.png`)
const image = await page.screenshot({type: 'png'})
await browser.close()
res.setHeader('Content-Type', 'image/png')
res.end(image)
}
この部分では、ページコンポーネントのスクリーンショット撮影に何かしら失敗した場合は、フォールバックとしてデフォルトの静的OGP画像( /images/og.png
)を返すようにしています。
最初は fs.readFileSync()
などを使って画像ファイルを直接読み込んでレスポンスとして返そうとしたのですが、ローカル開発環境とEdge Function上とでスクリプトファイルと /public
ディレクトリとの位置関係が異なっていて色々面倒だったので、Playwrightで画像を開いてスクリーンショットするという対応をとっています。
なお、もう一つ注意点として、ページコンポーネント側で画像を next/image
で出力している場合、Playwrightで読み込み完了した時点でまだ画像が表示されておらず(next/imageの遅延読み込み機能のため)スクリーンショットに映らないということが起こるので要注意です。
今回は、このページコンポーネントでは next/image
を使わないという対応をとりました。
2. 日本語フォント対応
この時点で、ローカルで
$ npx playwright install
して、ブラウザで http://localhost:3000/api/og?postId=1
などにアクセスしてみると、Chromiumが立ち上がってしばらく動いたあと、もとのブラウザ側に期待どおりOGP画像が表示されます。
ただし、このコードをVercelにデプロイして動かしてみると、残念ながら日本語が豆腐になります。
これは、Edge Function環境(=AWS Lanbda環境)に日本語フォントがインストールされていないことが原因です。
なので、
- Playwrightに日本語のフォントファイルを読み込ませる
- ページコンポーネント側で、テキストの表示に日本語のWebフォントを使用するようCSSを設定する
のいずれかの対応が必要になります。
前者だと、@vercel/og
を使う場合に必要となった、フォントをサブセット化した上でデプロイするといったワークアラウンドが必要になり面倒なので、今回は後者を採用します。
Webフォントの読み込みに時間がかかることを心配する人もいるかもしれませんが、そもそもPlaywrightの起動に数秒オーダーで時間がかかるので、誤差です🤷♂️
ページコンポーネント側の戻り値に、以下のような <style>
タグを追加することで対応できます。
<>
<Head>
{/* @see https://fonts.google.com/noto/specimen/Noto+Sans+JP */}
<style
dangerouslySetInnerHTML={{
__html: `
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+JP&display=swap');
* { font-family: 'Noto Sans JP', sans-serif; }
`,
}}
/>
</Head>
<Flex w={1200} h={630} bgColor="brand" align="center" p="40px">
{/* 中略 */}
</Flex>
</>
ちなみに、絵文字も表示できるようにしたい場合は、playwright-aws-lambdaのREADMEに書かれているとおり、/pages/api/og.ts
のほうに以下を追記するだけで対応できます。
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
const postId = Number(req.query.postId) || undefined
const viewport = {width: 1200, height: 630}
+ // @see https://github.com/JupiterOne/playwright-aws-lambda#loading-additional-fonts
+ await playwright.loadFont(
+ 'https://raw.githack.com/googlei18n/noto-emoji/master/fonts/NotoColorEmoji.ttf',
+ )
const browser = await playwright.launchChromium()
const page = await browser.newPage({viewport})
// 後略
3. その他のワークアラウンド
最後に、「結局原因はよく分からなかったけどなぜかVercel上で期待どおり動かなかった」ことがいくつかあったので、それに対して行った対処もまとめておきます。
まず、/pages/api/og.ts
側に以下の2行がどうやら必要でした。なぜVercel上でのみこれがないと期待どおり動作しないのか、正確な原因は未調査です。
// 前略
if (!response?.ok()) throw new Error()
+ // これらがないと一部の文字列が出力されないことがある
+ await page.emulateMedia({media: 'screen'})
+ await page.evaluate(() => document.fonts.ready)
const image = await page.screenshot({type: 'png'})
await browser.close()
// 後略
また、ページコンポーネント側でFlexboxの gap
を使っている場合に、Vercel上のPlaywrightによるスクリーンショットでのみなぜか gap
が 0
であるかのような見た目で撮影されるという現象がありました。謎です。
これも原因不明のままですが、gap
を使わずに margin
などに置き換えることで期待どおりの出力が得られました。
まとめ
というわけで、@vercel/og
が不便だったのでChakra UIで書いた普通のページコンポーネントを動的OGP画像として使えるようにした話でした。
Playwrightでスクリーンショットをとる方式だとどうしても画像の生成に数秒ぐらいは時間がかかってしまうので、その点だけがデメリットと言えます。が、使い慣れたChakra UIでOGP画像のデザインを組めるのは非常に開発体験がよかったです。
画像の生成に時間がかかる問題も、キャッシュ戦略を工夫することでほとんど気にならない状態を作れるのではないかと思っています。
よろしければ参考にしてみてください🍵
Discussion