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

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

Next.jsTypeScriptVercel

おはこんにちばんは。今回は 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 が利用できるライブラリです。

1yarn add canvas
2

プロジェクトの設定

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

./pages/[dynamic]/index.ts
1import { GetStaticPaths, GetStaticProps, NextPage } from 'next'
2import React from 'react'
3import { useRouter } from 'next/router';
4
5const DynamicPage: NextPage = () => {
6 const router = useRouter();
7 return (
8 <div>
9 <h1>{router.query.dynamic}</h1>
10 </div>
11 )
12}
13
14export const getStaticPaths: GetStaticPaths = async () => {
15 const paths = [...Array(10)].map((_, index) => ({
16 params: {
17 dynamic: `${index}`,
18 },
19 }))
20
21 return { paths, fallback: false };
22}
23
24export const getStaticProps: GetStaticProps = async (context) => {
25 return {
26 props: {},
27 }
28}
29
30export default DynamicPage
31

試しに画像生成してみる

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

./pages/api/[dynamic]/ogp.ts
1const createOgp = async (
2 req: NextApiRequest,
3 res: NextApiResponse
4): Promise<void> => {
5
6 const WIDTH = 1200 as const;
7 const HEIGHT = 630 as const;
8 const DX = 0 as const;
9 const DY = 0 as const;
10 const canvas = createCanvas(WIDTH, HEIGHT);
11 const ctx = canvas.getContext("2d");
12
13 ctx.fillStyle = "#FFF";
14 ctx.fillRect(DX, DY, WIDTH, HEIGHT);
15
16 const buffer = canvas.toBuffer();
17
18 res.writeHead(200, {
19 "Content-Type": "image/png",
20 "Content-Length": buffer.length,
21 });
22 res.end(buffer, "binary");
23};
24
25export default createOgp;
26

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

blog image

文字を表示してみる

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

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

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

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

./pages/api/[dynamic]/ogp.ts
1const createOgp = async (
2 req: NextApiRequest,
3 res: NextApiResponse
4): Promise<void> => {
5
6 const WIDTH = 1200 as const;
7 const HEIGHT = 630 as const;
8 const DX = 0 as const;
9 const DY = 0 as const;
10 const canvas = createCanvas(WIDTH, HEIGHT);
11
12 const ctx = canvas.getContext("2d");
13 ctx.fillStyle = "#FFF";
14 ctx.fillRect(DX, DY, WIDTH, HEIGHT);
15
16 registerFont(path.resolve("./fonts/ipagp.ttf"), {
17 family: "ipagp",
18 });
19 ctx.font = "60px ipagp";
20 ctx.fillStyle = "#000000";
21 ctx.textAlign = "center";
22 ctx.textBaseline = "middle";
23 ctx.fillText("わしはOGP画像を生成したい!!!!!", 600, 300);
24
25 const buffer = canvas.toBuffer();
26
27 res.writeHead(200, {
28 "Content-Type": "image/png",
29 "Content-Length": buffer.length,
30 });
31 res.end(buffer, "binary");
32};
33

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

blog image

背景画像を変えてみる

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

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

./pages/api/[dynamic]/ogp.ts
1const createOgp = async (
2 req: NextApiRequest,
3 res: NextApiResponse
4): Promise<void> => {
5 const WIDTH = 1200 as const;
6 const HEIGHT = 630 as const;
7 const DX = 0 as const;
8 const DY = 0 as const;
9 const canvas = createCanvas(WIDTH, HEIGHT);
10
11 const ctx = canvas.getContext("2d");
12 const backgroundImage = await loadImage(path.resolve("./public/ogp.jpg"));
13 ctx.drawImage(backgroundImage, DX, DY, WIDTH, HEIGHT);
14
15 registerFont(path.resolve("./fonts/ipagp.ttf"), {
16 family: "ipagp",
17 });
18
19 ctx.font = "60px ipagp";
20 ctx.fillStyle = "#000000";
21 ctx.textAlign = "center";
22 ctx.textBaseline = "middle";
23 ctx.fillText("わしはOGP画像を生成したい!!!!!", 600, 300);
24
25 const buffer = canvas.toBuffer();
26
27 res.writeHead(200, {
28 "Content-Type": "image/png",
29 "Content-Length": buffer.length,
30 });
31 res.end(buffer, "binary");
32};
33

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

blog image

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

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

./pages/api/[dynamic]/ogp.ts
1interface SeparatedText {
2 line: string;
3 remaining: string;
4}
5
6const createTextLine = (canvas: Canvas, text: string): SeparatedText => {
7 const context = canvas.getContext("2d");
8 const MAX_WIDTH = 1000 as const;
9
10 for (let i = 0; i < text.length; i += 1) {
11 const line = text.substring(0, i + 1);
12
13 if (context.measureText(line).width > MAX_WIDTH) {
14 return {
15 line,
16 remaining: text.substring(i + 1),
17 };
18 }
19 }
20
21 return {
22 line: text,
23 remaining: "",
24 };
25};
26
27const createTextLines = (canvas: Canvas, text: string): string[] => {
28 const lines: string[] = [];
29 let currentText = text;
30
31 while (currentText !== "") {
32 const separatedText = createTextLine(canvas, currentText);
33 lines.push(separatedText.line);
34 currentText = separatedText.remaining;
35 }
36 return lines;
37};
38
39const createOgp = async (
40 req: NextApiRequest,
41 res: NextApiResponse
42): Promise<void> => {
43
44 const { dynamic } = req.query;
45
46 const WIDTH = 1200 as const;
47 const HEIGHT = 630 as const;
48 const DX = 0 as const;
49 const DY = 0 as const;
50 const canvas = createCanvas(WIDTH, HEIGHT);
51 const ctx = canvas.getContext("2d");
52
53 registerFont(path.resolve("./fonts/ipagp.ttf"), {
54 family: "ipagp",
55 });
56
57 const backgroundImage = await loadImage(
58 path.resolve("./public/ogp.jpg")
59 );
60
61 ctx.drawImage(backgroundImage, DX, DY, WIDTH, HEIGHT);
62 ctx.font = "60px ipagp";
63 ctx.textAlign = "center";
64 ctx.textBaseline = "middle";
65
66 const title =
67 String(dynamic) + "のページのOGPだよーーー";
68
69 const lines = createTextLines(canvas, title);
70 lines.forEach((line, index) => {
71 const y = 314 + 80 * (index - (lines.length - 1) / 2);
72 ctx.fillText(line, 600, y);
73 });
74
75 const buffer = canvas.toBuffer();
76
77 res.writeHead(200, {
78 "Content-Type": "image/png",
79 "Content-Length": buffer.length,
80 });
81 res.end(buffer, "binary");
82};
83

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

1const { dynamic } = req.query;

それをtitleに渡しているので、動的に変わっている感じです。ここはアプリ毎に変えるといいですね。

あと、テキストが長いとはみ出てしまうので、createTextLinesでテキストを分割するようにしています。

1const title =
2 String(dynamic) + "のページのOGPだよーーー";
3
4const lines = createTextLines(canvas, title);
5 lines.forEach((line, index) => {
6 const y = 314 + 80 * (index - (lines.length - 1) / 2);
7 ctx.fillText(line, 600, y);
8});
9

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

blog image

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

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

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

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

./utils/server/ogpUtils.ts
1import { createCanvas, registerFont, loadImage, Canvas } from "canvas";
2import * as path from "path";
3import fs from "fs";
4
5interface SeparatedText {
6 line: string;
7 remaining: string;
8}
9
10const createTextLine = (canvas: Canvas, text: string): SeparatedText => {
11 const context = canvas.getContext("2d");
12 const MAX_WIDTH = 1000 as const;
13
14 for (let i = 0; i < text.length; i += 1) {
15 const line = text.substring(0, i + 1);
16
17 if (context.measureText(line).width > MAX_WIDTH) {
18 return {
19 line,
20 remaining: text.substring(i + 1),
21 };
22 }
23 }
24
25 return {
26 line: text,
27 remaining: "",
28 };
29};
30
31const createTextLines = (canvas: Canvas, text: string): string[] => {
32 const lines: string[] = [];
33 let currentText = text;
34
35 while (currentText !== "") {
36 const separatedText = createTextLine(canvas, currentText);
37 lines.push(separatedText.line);
38 currentText = separatedText.remaining;
39 }
40
41 return lines;
42};
43
44const createOgp = async (dynamic:number): Promise<void> => {
45
46 const WIDTH = 1200 as const;
47 const HEIGHT = 630 as const;
48 const DX = 0 as const;
49 const DY = 0 as const;
50 const canvas = createCanvas(WIDTH, HEIGHT);
51 const ctx = canvas.getContext("2d");
52
53 registerFont(path.resolve("./fonts/ipagp.ttf"), {
54 family: "ipagp",
55 });
56
57 const backgroundImage = await loadImage(path.resolve("./public/ogp.jpg"));
58
59 ctx.drawImage(backgroundImage, DX, DY, WIDTH, HEIGHT);
60 ctx.font = "60px ipagp";
61 ctx.textAlign = "center";
62 ctx.textBaseline = "middle";
63
64 const title = dynamic + "のページのOGPだよーーー";
65
66 const lines = createTextLines(canvas, title);
67 lines.forEach((line, index) => {
68 const y = 314 + 80 * (index - (lines.length - 1) / 2);
69 ctx.fillText(line, 600, y);
70 });
71
72 const buffer = canvas.toBuffer();
73 fs.writeFileSync(path.resolve(`./public/ogp/${dynamic}.png`), buffer);
74
75};
76
77export default createOgp;
78

./pages/[dynamic] /index.ts
1import { GetStaticPaths, GetStaticProps, NextPage } from 'next'
2import React from 'react'
3import { useRouter } from 'next/router';
4import Head from 'next/head';
5import fetchWrapper from '../../utils/FetchUtils';
6
7const DynamicPage: NextPage = () => {
8 const router = useRouter();
9 const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? '';
10 const { dynamic } = router.query;
11
12 return (
13 <>
14 <Head>
15 <title>Create Next App</title>
16 <link rel="icon" href="/favicon.ico" />
17 <meta property="og:image" key="ogImage" content={`${baseUrl}/ogp/${dynamic}.png`} />
18 <meta name="twitter:card" key="twitterCard" content="summary_large_image" />
19 <meta name="twitter:image" key="twitterImage" content={`${baseUrl}/ogp/${dynamic}.png`} />
20 </Head>
21 <div>
22 <h1>{dynamic}のページだよ</h1>
23 </div>
24 </>
25 )
26}
27
28
29export const getStaticPaths: GetStaticPaths = async () => {
30 const paths = [...Array(10)].map((_, index) => ({
31 params: {
32 dynamic: `${index}`,
33 },
34 }))
35
36 return { paths, fallback: false };
37}
38
39export const getStaticProps: GetStaticProps = async (context) => {
40 [...Array(10)].forEach((_, index) => {
41 void createOgp(index);
42 })
43
44 return {
45 props: {},
46 }
47}
48
49export default DynamicPage
50

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

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

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

1export const getStaticProps: GetStaticProps = async (context) => {
2 [...Array(10)].forEach((_, index) => {
3 void createOgp(index);
4 })
5
6 return {
7 props: {},
8 }
9}
10

本番設定

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

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

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

metaタグを追加してあげる

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

./pages/[dynamic]/index.ts
1<Head>
2 <meta property="og:image" key="ogImage" content={`${baseUrl}/ogp/${dynamic}.png`} />
3 <meta name="twitter:card" key="twitterCard" content="summary_large_image" />
4 <meta name="twitter:title" content={`これはテストだよ`} />
5 <meta name="twitter:description" content={"これはテストだよ"} />
6 <meta name="twitter:image" key="twitterImage" content={`${baseUrl}/ogp/${dynamic}.png`} />
7</Head>

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

./.env.local
1NEXT_PUBLIC_BASE_URL="http://localhost:3000"

Vercelにデプロイしてみる

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

blog image

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

blog image

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

blog image

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

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

blog image

さいごに

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

参考

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