no7.space
🖼️

Next.js で記事の OGP 画像を自動生成する

最近よく見るようになった、記事タイトルを含めたサイトの og:image ですが、Next.js の場合、簡単に作れることがわかったのでやってみました。

'next/og'

Next.js にはまさに OGP のためにあるみたいな名前の next/og というものがあり、これを使うことで実現できるみたいです。

ということで、

app/blog/[slug]/og.png/route.tsx

にファイルを作ります。

import fs from 'node:fs/promises'
import path from 'node:path'
import matter from 'gray-matter'
import { ImageResponse } from 'next/og'

export const dynamic = 'force-dynamic'
export const dynamicParams = false

type Props = {
  params: Promise<{ slug: string }>
}

// 静的パスを生成する
export async function generateStaticParams() {
  // content/blog ディレクトリの .md ファイル一覧を取得
  const dir = path.join(process.cwd(), 'content/blog')
  const files = await fs.readdir(dir)
  return files
    .filter((f) => f.endsWith('.md'))
    .map((f) => ({ slug: f.replace(/\.md$/, '') }))
}

export async function GET(_: Request, { params }: Props) {
  const { slug } = await params
  const filePath = path.join(process.cwd(), 'content/blog', `${slug}.md`)
  let fileContent: string

  const FontData = {
    IBMPlexSansJP: await fs.readFile(
      path.join(
        process.cwd(),
        'public',
        'assets',
        'fonts',
        'IBM_Plex_Sans_JP',
        'IBMPlexSansJP-Light.ttf'
      )
    ),
  }

  try {
    fileContent = await fs.readFile(filePath, 'utf-8')
  } catch {
    return new Response('Not Found', { status: 404 })
  }

  try {
    const backGroundImage = await fs.readFile(
      path.join(process.cwd(), 'public', 'assets', 'img', 'ogp-base.png')
    )
    // Base64エンコード
    const base64 = Buffer.from(backGroundImage).toString('base64')
    const bgSrc = `data:image/png;base64,${base64}`

    const { data } = matter(fileContent)

    return new ImageResponse(
      <div
        style={{
          display: 'flex',
          width: '100%',
          height: '100%',
          flexDirection: 'column',
          alignItems: 'center',
          justifyContent: 'center',
        }}
      >
        <img
          src={bgSrc}
          alt="title"
          style={{
            width: '100%',
            height: '100%',
            objectFit: 'cover',
            position: 'absolute',
            top: 0,
            left: 0,
            zIndex: -1,
          }}
        />
        <div
          style={{
            display: 'flex',
            flexDirection: 'column',
            alignItems: 'center',
            justifyContent: 'center',
            padding: '20px',
            width: '1000px',
            height: '525px',
          }}
        >
          <div
            style={{
              fontSize: 80,
              flex: 3,
            }}
          >
            {data.emoji || '📝'}
          </div>
          <div
            style={{
              fontSize: 60,
              fontWeight: 'bold',
              padding: '0 20px',
              textShadow: '2px 2px 4px rgba(0, 0, 0, 0.5)',
              flex: 4,
            }}
          >
            {data.title}
          </div>
          <div
            style={{
              flex: 2,
              fontSize: 32,
            }}
          >
            {data.created}
          </div>
        </div>
      </div>,
      {
        width: 1200,
        height: 630,
        // フォントの読み込み
        fonts: [
          {
            name: 'IBMPlexSansJP',
            data: FontData.IBMPlexSansJP,
            style: 'normal',
          },
        ],
        emoji: 'twemoji',
      }
    )
  } catch (e) {
    console.error(e)
    return new Response('Internal Server Error', { status: 500 })
  }
}

これで、記事URL + /og.png でOGP用の画像が表示されるようになります。

基本的には、ビルド時に全記事分つくるので、記事詳細と似てますね。

ただ、JSX を返す代わりにこちらは ImageResponse を返しています。
実際これって画像がかえってくるんですが、中身は普通に JSX ですよね。

これは、内部的に JSX を SVG 化して、それを更に PNG に変換する、みたいなことをしているらしいです。
なので、普通にページを作るのと同じ感覚でレイアウトをつくれるのは楽でいいですね。
ただし、画像としてかえってくるので当然 DevTools でのデバッグはできないです 💦

とはいえ、 Canvas でやろうとすると勝手が全然違って大変だった記憶がありますし、ソレに比べたら全然楽です。

Note

一応、 options.debugtrue にして生成すると、要素の境界線が描画されるようになるので、多少やりやすくなるかもですが、ある程度別の場所でレイアウトを整えてしまったほうが楽なような気もします。

フォントの指定

画像生成にあたり、フォントを読み込んで指定してあげないと正しく表示ができないらしいです。
また、ここでのフォント指定には next/font/google は使えないらしいので、
普通にWeb版を読み込むか、プロジェクト内に当該フォントを含めてしまうしかないようです。

絵文字の種類

絵文字データ( 🎉 とか 🐈 とか)は、いくつかのスタイルが用意されていて、
デフォルトだと X で使われてる Twemoji になりますが、ほかにも noto color emojiblobmojiopenmoji が使えます

emoji?: 'twemoji' | 'blobmoji' | 'noto' | 'openmoji' = 'twemoji'

ここでは明示的に twemoji を指定してますが、デフォルト値が twemoji なので、 Twemoji を使いたい場合は省略しても大丈夫そうです。

Twemoji

Twemoji は、X (Twitter) で使われてる絵文字です。

Noto Color Emoji

Noto Color Emojiは、名前からもわかる通り、Google の Noto シリーズの1つで、 Android や Chrome OS で表示される絵文字もこれっぽいです。

Google Fonts にあるので、ウェブフォントとしてサイトにセットアップすればサイトの絵文字をこれにすることも可能みたいです。

Notoシリーズだけど共同開発してた Adobe 側の源ノシリーズには流石にないっぽいかな?

Blobmoji

blobmoji は、かつて Google が Android 端末で使ってた絵文字です。
独特の形の愛嬌のある絵文字なんですが(私はこれをプリンと呼んでましたw)いつのまにか Noto Color Emoji に変わったみたいですね。

今は、Google のソフトウェアキーボードである、Gboard から選べるステッカーに、残ってるようです。

Blobmoji sticker

OpenMoji

OpenMoji はその名の通りのオープンソースな絵文字です。
独特の雰囲気のある絵文字ですが、個人的には結構好きです

メタデータの設定

最後に、ページをビルドする際のメタデータに og:image を含めるように generateMetadata を調整します

OGP絡みは、

  • openGraph.title タイトル
  • openGraph.description 概要
  • openGraph.url URL
  • openGraph.type タイプ。投稿なら article かな
  • openGraph.images[]
    • url 今作った画像のパス。記事のパーマリンク + /og.png
    • width 画像の幅
    • height 画像の高さ
    • alt 画像の alt

images はオブジェクトの配列なので、複数サイズ登録できるっぽいのかな。
まぁとりあえず1200 x 630 1枚あれば最低限なんとかなる気はしますが。

Important

og:image はこの横長のがメジャーなサイズですが、最近流行りの埋め込みカードだと、中央で正方形にクロップされることが多いみたいなので、
絶対に見せたい要素は画像中央の 630 x 630 px の範囲内に収めると良いらしいです。

テストする

ローカルでビルドしてから開いてみて、もんだなく画像が取れてそうだったら、実際にデプロイしてみてから、
Facebook のシェアデバッガー に URL を突っ込んでチェックしましょう。

参考

https://www.riku-mono.me/posts/nextjs-app-ogp
https://nextjs.org/docs/app/api-reference/functions/image-response