🪬

vercelのog image generationを使用して、Next.jsで動的にOG画像を自動生成する

2022/12/24に公開

この記事は ミライトデザイン Advent Calendar 2022 の 23 日目の記事です。

https://qiita.com/advent-calendar/2022/miraito-inc

前日の kakiuchi の記事に続いて書いていきます。

https://qiita.com/tkek321/items/2aa7757514532dbda2c4

はじめに

2022/10/10 に vercel から OG Image を自動生成するライブラリの発表がありました。

https://vercel.com/blog/introducing-vercel-og-image-generation-fast-dynamic-social-card-images

このライブラリを使って Next.js で作ったページの OG 画像を動的に生成する方法を紹介します。

前回の記事を例にこんな感じで表示させることができます。

https://twitter.com/yyykms123/status/1601279551430328320

環境

  • react: 18.2
  • next: 12.3.1
  • vercel/og: 0.0.20

OG画像を生成するライブラリ

vercel が発表した @vercel/og というライブラリは、Vercel Edge Functions で HTML/CSS(JSX)でマークアップしたものを動に OG 画像として生成できるライブラリです。

Vercel Edge Functions とは、エッジで関数を実行できる環境となるので、@vercel/ogruntime: 'edge' という設定をして Edge Runtime で使用する必要があります。

また、Next.js は v12.2.3 以降を使用する必要があります。

コアエンジンでは Satori と Resvg という 2 つのライブラリを使用して、最終的に PNG に変換しています。

具体的には、Satori というライブラリが HTML/CSS(JSX)から SVG を生成し、Resvg が生成された SVG を PNG に変換しているような流れで、これを Edge 環境で行っています。(JSX → SVG → PNG)

https://vercel.com/docs/concepts/functions/edge-functions/og-image-generation

使用方法

基本的には公式の通り進めてけば問題ないです。
サンプルリポジトリを作成しているので参考に見てみてください。

https://github.com/yukimasa/vercel-og-sample

画像生成の設定

まずはライブラリのインストールをします。

yarn add @vercel/og

ライブラリをインストールしたら pages/api 配下に og.tsx を作成して、og 画像を生成するための API エンドポイントを作成します。

config に runtime: 'edge' を指定して ImageResponse を return するだけです。

今回は公式の「Hello world!」の画像で設定をしています。

pages/api/og.tsx
import { ImageResponse } from '@vercel/og';

export const config = {
  runtime: 'edge',
};

export default function handler() {
  return new ImageResponse(
    (
      <div
        style={{
          fontSize: 128,
          background: 'white',
          width: '100%',
          height: '100%',
          display: 'flex',
          textAlign: 'center',
          alignItems: 'center',
          justifyContent: 'center',
        }}
      >
        Hello world!
      </div>
    ),
    {
      width: 1200,
      height: 600,
    },
  );
}

作成したら yarn dev でサーバーを立ち上げ http://localhost:3000/api/og を確認すると画像が生成されていることが確認できます。

スクリーンショット

画像の生成はこれだけです。

OGPの設定

ただ、このままだと画像が生成されただけなので、OG 画像として表示されるように、page の <Head><meta> タグを追加します。

ここでは、試しにトップページで確認できるように pages/index.tsx で設定します。

実際に確認ができるように vercel にデプロイして絶対パスを指定しておきましょう。

pages/index.tsx
<meta
  property="og:image"
  content="<https://vercel-og-sample.vercel.app/api/og>"
/>

デプロイできたら OGP を確認できるツールで確認してみましょう。

https://rakko.tools/tools/9/

スクリーンショット

先程確認した画像で OG 画像が設定されていることが確認できます。

ページごとで動的に画像を生成する

画像を生成して OG 画像として設定できたことが確認できましたが、このままでは全ページ「Hello world!」の画像となってしまいます。

そのため、次はページごとに動的に画像が変わるように設定していきます。

ページごとに OG 画像を生成するためには、query パラメーターを使用して動的にページタイトルやその他要素を画像に挿入できます。

https://vercel-og-sample.vercel.app/api/og?title=hogehoge&publishedAt=2020-12-23

まずは、先程作成した og.tsx を下記のように修正します。

pages/api/og.tsx
import { ImageResponse } from "@vercel/og";
import { NextRequest } from "next/server";

export const config = {
  runtime: "edge",
};

export default function (req: NextRequest) {
  const { searchParams } = new URL(req.url);
  const hasTitle = searchParams.has("title");
  const title = hasTitle
    ? searchParams.get("title")?.slice(0, 100)
    : "My default title";

  return new ImageResponse(
    (
      <div
        style={{
          fontSize: 128,
          background: "white",
          width: "100%",
          height: "100%",
          display: "flex",
          textAlign: "center",
          alignItems: "center",
          justifyContent: "center",
        }}
      >
        {title}
      </div>
    ),
    {
      width: 1200,
      height: 600,
    }
  );
}

最初と違う点としては、 searchParams でパラメーターから title のデータを取得して、画像のテキストに挿入しています。

試しに先程トップページで指定した url に title のパラメーターを付与して確認してみます。(画像の確認なので、localhost で問題なし)

開発環境で、api のパスにアクセスしてみます。

http://localhost:3000/api/og?title=OG画像を動的に生成

スクリーンショット

するとパラメーターで渡した title が画像に表示されていることを確認できました。

サンプルブログを作成し、ページごとにパラメーターを変更してOG画像を設定する

あとは、トップページではなくページごとの <meta> タグにクエリーパラメーターを設定するようにします。

サンプルとして、ブログのように一覧ページと詳細ページを作成して各ページで動的に OG 画像が生成されるようにします。

APIの作成

まずはブログのダミーデータを取得できるように API を作成します。

Post の型定義。

types/post.ts
export type Post = {
  id: string;
  title: string;
};

ブログのダミーデータ。

mock/posts.ts
import { Post } from "../types/post";

export const postsData: Post[] = [
  {
    id: "4d20c87f-3497-125b-209d-6aa0c44333ac",
    title: "OG画像を動的に生成する",
  },
  {
    id: "46ab05ad-f790-574e-5367-a88a21bf6916",
    title: "Next.jsでJamstackな個人サイトを作った",
  },
];

post 一覧取得 API。

pages/api/posts/index.ts
import type { NextApiRequest, NextApiResponse } from "next";
import { postsData } from "../../../mock/posts";
import { Post } from "../../../types/post";

export default function handler(
  req: NextApiRequest,
  res: NextApiResponse<Post[]>
) {
  res.status(200).json(postsData);
}

post 詳細 API。

pages/api/posts/[id].ts
import type { NextApiRequest, NextApiResponse } from "next";
import { postsData } from "../../../mock/posts";
import { Post } from "../../../types/post";

export default function handler(
  req: NextApiRequest,
  res: NextApiResponse<Post>
) {
  const posts = postsData;
  const { id } = req.query;
  const post = posts.filter((post) => post.id === id)[0];

  res.status(200).json(post);
}

これで、ブログのデータを取得できる API ができました。

試しに確認してみます。

http://localhost:3000/api/posts

スクリーンショット

http://localhost:3000/api/posts/4d20c87f-3497-125b-209d-6aa0c44333ac

スクリーンショット

これで、一覧及び詳細データが取得できました。

API ができたら、この時点でデプロイをしておきます。

この後ページを作成しますが、API からダミーデータを取得して事前ビルドをする必要があるので、本番環境で API が無いと事前ビルドでデータが取得できずにエラーとなってしまいます。

ページの作成

あとは一覧と詳細のページファイルを作成しますが、開発環境と本番環境で url が変わるように env の設定をしておきます。

.env.local
NEXT_PUBLIC_APP_URL=http://localhost:3000

本番環境では、自身がデプロイした環境で設定してください。

vercel の場合だと、ダッシュボードの Settings > Environment Variables で設定ができます。

スクリーンショット

では、ダミーデータを API から取得して、一覧画面に表示させるためのページファイルを作成します。

pages/posts/index.tsx
import { GetStaticProps, NextPage } from "next";
import Head from "next/head";
import Link from "next/link";
import { Post } from "../../types/post";

type DataType = {
  contents: Post[];
};

export default function Posts({ contents }: DataType) {
  return (
    <>
      <Head>
        <title>posts</title>
        <meta name="description" content="記事一覧" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
      </Head>

      <main
        style={{
          display: "flex",
          flexDirection: "column",
          justifyContent: "space-between",
          alignItems: "center",
          padding: "6rem",
        }}>
        <ul>
          {contents.map((post) => (
            <Link key={post.id} href={`/posts/${post.id}`}>
              <li>{post.title}</li>
            </Link>
          ))}
        </ul>
      </main>
    </>
  );
}

export const getStaticProps: GetStaticProps<DataType> = async () => {
  const data = await fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/posts`).then(
    (data) => data.json()
  );

  return {
    props: {
      contents: data,
    },
  };
};

一覧ページを確認すると、ダミーデータのリストが表示されたことを確認できます。

http://localhost:3000/posts

スクリーンショット

次に詳細画面のページファイルを作成します。

pages/posts/[id].tsx
import {
  GetStaticPaths,
  GetStaticPathsResult,
  GetStaticProps,
  InferGetStaticPropsType,
  NextPage,
} from "next";
import Head from "next/head";
import { Post } from "../../types/post";
import { ParsedUrlQuery } from "querystring";

type Params = {
  id: string;
} & ParsedUrlQuery;

type DataType = { post: Post };

type Props = InferGetStaticPropsType<typeof getStaticProps>;

export default function Posts({ post }: Props) {
  return (
    <>
      <Head>
        <title>{post.title}</title>
        <meta name="description" content={post.title} />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <meta
          property="og:image"
          content={`${process.env.NEXT_PUBLIC_APP_URL}/api/og?title=${post.title}`}
        />
      </Head>

      <main
        style={{
          display: "flex",
          flexDirection: "column",
          justifyContent: "space-between",
          alignItems: "center",
          padding: "6rem",
        }}>
        <p>id: {post.id}</p>
        <h2>{post.title}</h2>
      </main>
    </>
  );
}

export const getStaticPaths: GetStaticPaths = async (): Promise<
  GetStaticPathsResult<Params>
> => {
  const data = await fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/posts`).then(
    (data) => data.json()
  );

  const paths = data.map((post: Post) => {
    return {
      params: {
        id: post.id,
      },
    };
  });

  return { paths, fallback: false };
};

export const getStaticProps: GetStaticProps<DataType, Params> = async ({
  params,
}) => {
  if (!params?.id) {
    throw new Error("Error: ID not found");
  }

  const post = await fetch(
    `${process.env.NEXT_PUBLIC_APP_URL}/api/posts/${params.id}`
  ).then((data) => data.json());

  return {
    props: { post },
  };
};

一覧からブログを選択すると詳細画面に遷移され、id と title が表示されることが確認できました。

http://localhost:3000/posts/46ab05ad-f790-574e-5367-a88a21bf6916

スクリーンショット

また、詳細ページの meta タグを確認してみてください。

<meta
  property="og:image"
  content={`${process.env.APP_URL}/api/og?title=${post.title}`}
/>;

上記のように記述していますが、ビルドされたあとは API から取得したデータのタイトルが表示されていることが確認できます。

スクリーンショット

これで、ページごとに OG 画像が生成されるようになったので、最後にデプロイして OGP 確認ツールで見てみます。

スクリーンショット

ページのタイトルを取得して OG 画像が設定されていることが確認できました。

その他カスタマイズ

OG 画像の設定はできるようになったので、あとはスタイルをカスタマイズして完成させます。

公式の方でサンプルを用意していたり、play ground で確認できたりするので、後は自分好みにカスタマイズしてスタイルを整えていきましょう。

https://vercel.com/docs/concepts/functions/edge-functions/og-image-examples

https://og-playground.vercel.app/

スクリーンショット

まとめ

もともと OG 画像は自分で作成して設定していたりしたので、自動で生成してくれると楽ですし、デザインも統一されるのでとてもいいなと思いました。

Vercel が用意してくれているライブラリで簡単に OG 画像が生成できるので、よかったら試してみてください。

次は「NFT と仮想通貨とブロックチェーン関連」について書いてくれる FrozenVoice さんの記事です。

https://qiita.com/FrozenVoice/items/9b5a607ec6c8d8ef9d54

Discussion