ブログトップ画像

【Next.js × Vercel】OGP画像を動的生成してみた

フロントエンド

おはこんにちばんは。今回は Next.js で OGP画像 の動的生成をしてみました!

自作したブログなどをどうせなら Twitter や SNS 等の SNS にシェアした際に、OGP 用画像を表示して視覚的にもアピールしたいですよね!分かります。私もそうでした。でも、どうやってページ毎に OGP画像 を生成すればいいのか分からんですよね。

そこで、今回は Next.js で Vercel にデプロイ時のOGP画像を動的に生成する方法を解説しようと思います。
サンプルコードはこちら

実行環境

  • Next.js 10.0.0
  • React 17.0.1
  • Canves 2.6.1


必要なライブラリをインストール

今回は Next.js のサーバーサイド の処理で画像生成していくので、 node-canvas というライブラリを利用します。サーバサイドで Canvas が利用できるライブラリです。

yarn add canvas


プロジェクトの設定

次にページ毎で動的に画像生成が行われるか、確認用の静的なページを雑に作成しておきます。既にそういったページがある方はスルーでOKです!

./pages / [dynamic] /index.ts

import { GetStaticPaths, GetStaticProps, NextPage } from 'next'
import React from 'react'
import { useRouter } from 'next/router';

const DynamicPage: NextPage = () => {
  const router = useRouter();
  return (
    <div>
      <h1>{router.query.dynamic}</h1>
    </div>
  )
}

export const getStaticPaths: GetStaticPaths = async () => {
  const paths = [...Array(10)].map((_, index) => ({
    params: {
      dynamic: `${index}`,
    },
  }))
  
  return { paths, fallback: false };
}

export const getStaticProps: GetStaticProps = async (context) => {
  return {
    props: {},
  }
}

export default DynamicPage


試しに画像生成してみる

では、早速画像を試しに生成してみましょう!まずは、確認用で Next.js のAPI Router を利用して画像生成していきます。そのため、http://localhost:3000/api/ページID/ogp がエンドポイントの API を作成します。

./pages/api/[dynamic]/ogp.ts

const createOgp = async (
  req: NextApiRequest,
  res: NextApiResponse
): Promise<void> => {

  const WIDTH = 1200 as const;
  const HEIGHT = 630 as const;
  const DX = 0 as const;
  const DY = 0 as const;
  const canvas = createCanvas(WIDTH, HEIGHT);
  const ctx = canvas.getContext("2d");

  ctx.fillStyle = "#FFF";
  ctx.fillRect(DX, DY, WIDTH, HEIGHT);

  const buffer = canvas.toBuffer();

  res.writeHead(200, {
    "Content-Type": "image/png",
    "Content-Length": buffer.length,
  });
  res.end(buffer, "binary");
};

export default createOgp;


では、早速エンドポイント( http://localhost:3000/api/ページID/ogp )をブラウザで叩いてみましょう!以下のような画像が表示されれば成功です。画像サイズは調べた感じ、縦630px×横1200px が推奨サイズのようなのでこのサイズにしました。


文字を表示してみる

画像の表示が出来たと思うので、ブログのタイトルなどを表示するための文字を表示してみましょう!

まずは、環境依存によってフォントが変わらないようにフォントを用意します。今回は適当にIPAから ipagp.ttf を拾ってきてルートのfontフォルダに配置しました。Google Fontのこことか使うのもありかもですね。

フォントにはライセンスがあるので、利用時はライセンスの確認を必ずしてくださいね

では、以下のように先程のコードを変えてみましょう!

./pages/api/[dynamic]/ogp.ts

const createOgp = async (
  req: NextApiRequest,
  res: NextApiResponse
): Promise<void> => {

  const WIDTH = 1200 as const;
  const HEIGHT = 630 as const;
  const DX = 0 as const;
  const DY = 0 as const;
  const canvas = createCanvas(WIDTH, HEIGHT);

  const ctx = canvas.getContext("2d");
  ctx.fillStyle = "#FFF";
  ctx.fillRect(DX, DY, WIDTH, HEIGHT);

  registerFont(path.resolve("./fonts/ipagp.ttf"), {
    family: "ipagp",
  });
  ctx.font = "60px ipagp";
  ctx.fillStyle = "#000000";
  ctx.textAlign = "center";
  ctx.textBaseline = "middle";
  ctx.fillText("わしはOGP画像を生成したい!!!!!", 600, 300);

  const buffer = canvas.toBuffer();

  res.writeHead(200, {
    "Content-Type": "image/png",
    "Content-Length": buffer.length,
  });
  res.end(buffer, "binary");
};


以下のように熱いメッセージが表示されれば成功です。


背景画像を変えてみる

良い感じに画像が生成できるようになりましたね。ただ、これだとちょっと寂しいので背景画像を変えてみましょう!背景画像はルートの public/ogp.jpgに今回は配置しました。
では、以下のように先程のコードを変えてみましょう!

./pages/api/[dynamic]/ogp.ts

const createOgp = async (
  req: NextApiRequest,
  res: NextApiResponse
): Promise<void> => {
  const WIDTH = 1200 as const;
  const HEIGHT = 630 as const;
  const DX = 0 as const;
  const DY = 0 as const;
  const canvas = createCanvas(WIDTH, HEIGHT);

  const ctx = canvas.getContext("2d");
  const backgroundImage = await loadImage(path.resolve("./public/ogp.jpg"));
  ctx.drawImage(backgroundImage, DX, DY, WIDTH, HEIGHT);

  registerFont(path.resolve("./fonts/ipagp.ttf"), {
    family: "ipagp",
  });

  ctx.font = "60px ipagp";
  ctx.fillStyle = "#000000";
  ctx.textAlign = "center";
  ctx.textBaseline = "middle";
  ctx.fillText("わしはOGP画像を生成したい!!!!!", 600, 300);

  const buffer = canvas.toBuffer();

  res.writeHead(200, {
    "Content-Type": "image/png",
    "Content-Length": buffer.length,
  });
  res.end(buffer, "binary");
};


以下のように背景が変われば成功です!


動的にテキストを表示してみる

ここまできたら、あとはテキストをページ毎に変えるだけです。では、以下のように先程のコードを変えてみましょう!

./pages/api/[dynamic]/ogp.ts

interface SeparatedText {
  line: string;
  remaining: string;
}

const createTextLine = (canvas: Canvas, text: string): SeparatedText => {
  const context = canvas.getContext("2d");
  const MAX_WIDTH = 1000 as const;

  for (let i = 0; i < text.length; i += 1) {
    const line = text.substring(0, i + 1);

    if (context.measureText(line).width > MAX_WIDTH) {
      return {
        line,
        remaining: text.substring(i + 1),
      };
    }
  }

  return {
    line: text,
    remaining: "",
  };
};

const createTextLines = (canvas: Canvas, text: string): string[] => {
  const lines: string[] = [];
  let currentText = text;

  while (currentText !== "") {
    const separatedText = createTextLine(canvas, currentText);
    lines.push(separatedText.line);
    currentText = separatedText.remaining;
  }
  return lines;
};

const createOgp = async (
  req: NextApiRequest,
  res: NextApiResponse
): Promise<void> => {

  const { dynamic } = req.query;

  const WIDTH = 1200 as const;
  const HEIGHT = 630 as const;
  const DX = 0 as const;
  const DY = 0 as const;
  const canvas = createCanvas(WIDTH, HEIGHT);
  const ctx = canvas.getContext("2d");

  registerFont(path.resolve("./fonts/ipagp.ttf"), {
    family: "ipagp",
  });

  const backgroundImage = await loadImage(
    path.resolve("./public/ogp.jpg")
  );

  ctx.drawImage(backgroundImage, DX, DY, WIDTH, HEIGHT);
  ctx.font = "60px ipagp";
  ctx.textAlign = "center";
  ctx.textBaseline = "middle";

  const title =
    String(dynamic) + "のページのOGPだよーーー";

  const lines = createTextLines(canvas, title);
  lines.forEach((line, index) => {
    const y = 314 + 80 * (index - (lines.length - 1) / 2);
    ctx.fillText(line, 600, y);
  });
  
  const buffer = canvas.toBuffer();

  res.writeHead(200, {
    "Content-Type": "image/png",
    "Content-Length": buffer.length,
  });
  res.end(buffer, "binary");
};


以下でページIDを取得しています。

  const { dynamic } = req.query;


それをtitleに渡しているので、動的に変わっている感じです。ここはアプリ毎に変えるといいですね。
あと、テキストが長いとはみ出てしまうので、createTextLinesでテキストを分割するようにしています。

  const title =
    String(dynamic) + "のページのOGPだよーーー";

  const lines = createTextLines(canvas, title);
  lines.forEach((line, index) => {
    const y = 314 + 80 * (index - (lines.length - 1) / 2);
    ctx.fillText(line, 600, y);
  });


以下のように ページID 毎にテキストが変われば成功です!


画像を出力するようにする

ここまでで画像生成は出来るようになりました。ただ、静的サイトなどでは毎回エンドポイントを叩いて画像生成する必要もないのでビルド時のみ実行し、指定フォルダに出力するようにします。

今回はルートのpublicにogpというフォルダを作成し(.gitkeepは作成しておいてください)、そこのフォルダに生成した画像を出力するようにします。またAPI Router も利用する必要がなくなるので、/utils/server/ogpUtils.tsに今まで実装してきた処理を移行します。

では、以下のように先程のコードを変えてみましょう!

./utils/server/ogpUtils.ts 

import { createCanvas, registerFont, loadImage, Canvas } from "canvas";
import * as path from "path";
import fs from "fs";

interface SeparatedText {
  line: string;
  remaining: string;
}

const createTextLine = (canvas: Canvas, text: string): SeparatedText => {
  const context = canvas.getContext("2d");
  const MAX_WIDTH = 1000 as const;

  for (let i = 0; i < text.length; i += 1) {
    const line = text.substring(0, i + 1);

    if (context.measureText(line).width > MAX_WIDTH) {
      return {
        line,
        remaining: text.substring(i + 1),
      };
    }
  }

  return {
    line: text,
    remaining: "",
  };
};

const createTextLines = (canvas: Canvas, text: string): string[] => {
  const lines: string[] = [];
  let currentText = text;

  while (currentText !== "") {
    const separatedText = createTextLine(canvas, currentText);
    lines.push(separatedText.line);
    currentText = separatedText.remaining;
  }

  return lines;
};

const createOgp = async (dynamic:number): Promise<void> => {

  const WIDTH = 1200 as const;
  const HEIGHT = 630 as const;
  const DX = 0 as const;
  const DY = 0 as const;
  const canvas = createCanvas(WIDTH, HEIGHT);
  const ctx = canvas.getContext("2d");

  registerFont(path.resolve("./fonts/ipagp.ttf"), {
    family: "ipagp",
  });

  const backgroundImage = await loadImage(path.resolve("./public/ogp.jpg"));

  ctx.drawImage(backgroundImage, DX, DY, WIDTH, HEIGHT);
  ctx.font = "60px ipagp";
  ctx.textAlign = "center";
  ctx.textBaseline = "middle";

  const title = dynamic + "のページのOGPだよーーー";

  const lines = createTextLines(canvas, title);
  lines.forEach((line, index) => {
    const y = 314 + 80 * (index - (lines.length - 1) / 2);
    ctx.fillText(line, 600, y);
  });

  const buffer = canvas.toBuffer();
  fs.writeFileSync(path.resolve(`./public/ogp/${dynamic}.png`), buffer);

};

export default createOgp;


./pages / [dynamic] /index.ts

import { GetStaticPaths, GetStaticProps, NextPage } from 'next'
import React from 'react'
import { useRouter } from 'next/router';
import Head from 'next/head';
import fetchWrapper from '../../utils/FetchUtils';

const DynamicPage: NextPage = () => {
  const router = useRouter();
  const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? '';
  const { dynamic } = router.query; 

  return (
    <>
      <Head>
        <title>Create Next App</title>
        <link rel="icon" href="/favicon.ico" />
        <meta property="og:image" key="ogImage" content={`${baseUrl}/ogp/${dynamic}.png`} />
        <meta name="twitter:card" key="twitterCard" content="summary_large_image" />
        <meta name="twitter:image" key="twitterImage" content={`${baseUrl}/ogp/${dynamic}.png`} />
      </Head>
      <div>
        <h1>{dynamic}のページだよ</h1>
      </div>
    </>
  )
}


export const getStaticPaths: GetStaticPaths = async () => {
  const paths = [...Array(10)].map((_, index) => ({
    params: {
      dynamic: `${index}`,
    },
  }))
  
  return { paths, fallback: false };
}

export const getStaticProps: GetStaticProps = async (context) => {
  [...Array(10)].forEach((_, index) => {
    void createOgp(index);
  })

  return {
    props: {},
  }
}

export default DynamicPage


まず、画像生成APIですが、以下のように fs を利用し指定のフォルダに生成した画像を出力するようにしています。

  fs.writeFileSync(path.resolve(`./public/ogp/${id}.png`), buffer);


そして該当のページで画像生成の処理をビルド時に実行するようにしています。動的に変わる要素を引数で受け取るようにしています。

export const getStaticProps: GetStaticProps = async (context) => {
  [...Array(10)].forEach((_, index) => {
    void createOgp(index);
  })

  return {
    props: {},
  }
}


本番設定

ローカルでは動作するようになりましたが、まだ Vercel 環境では動作しません。

ルートに canvas_lib64 というフォルダを作成し、このライブラリを入れてあげます。次にデプロイ時に実行されるスクリプトを package.jsonに追加します。これで動作するはずです。

./package.json

"now-build": "cp canvas_lib64/*so.1 node_modules/canvas/build/Release/ && yarn build"


metaタグを追加してあげる

ここまで出来たらあとは該当ページで metaタグを追加してあげるだけです。これで Twitter でシェアする際、OGP画像と認識してくれます。

./pages / [dynamic] /index.ts

<Head>
  <meta property="og:image" key="ogImage" content={`${baseUrl}/ogp/${dynamic}.png`} />
  <meta name="twitter:card" key="twitterCard" content="summary_large_image" />
  <meta name="twitter:title" content={`これはテストだよ`} />
  <meta name="twitter:description" content={"これはテストだよ"} />
  <meta name="twitter:image" key="twitterImage" content={`${baseUrl}/ogp/${dynamic}.png`} />
</Head>


baseUrlは環境変数で指定しています。

./.env.local

NEXT_PUBLIC_BASE_URL="http://localhost:3000"


Vercelにデプロイしてみる

該当リポジトリをimportしてあげます。



ここは特に変更せず、Deploy でOKです!これでデプロイされるはずです。


ただ、環境変数を設定する必要があるので、以下でNEXTPUBLICBASE_URLの値(サイトのドメイン)を設定してあげてください。また、環境変数を反映するためにもう一度デプロイし直してください!


最後に動作確認をします。このページで該当ページのURLを入力して試してみてください。

以下のように表示されれば、成功です!お疲れ様でした。

さいごに

動的に OGP画像 の生成を意外と簡単に実装出来ました。こういうの実装できるようになると楽しいですね!

参考

https://zenn.dev/dala/books/nextjs-firebase-service