RemixでmicroCMSを使ったブログサイトを構築してみた〜OG画像の自動生成〜

RemixとmicroCMSで作成したブログにOG画像の自動生成機能を実装してみました。
2022.02.08

はじめに

こんにちは、CX事業本部MAD事業部の森茂です。

ブログで記事を書くと欲しくなる機能のひとつにOG画像を自動生成する機能があると思います。今回はRemixを使ってOG画像を自動生成する機能を実装してみました。

OG画像を自動生成する方法としては、canvasを利用する方法とヘッドレスブラウザ等でスクリーンショット画像を利用する方法がよく取られますが、今回はPuppeteerを利用してスクリーンショットを撮影しその画像をOG画像として利用する方法を選択しました。

なおデプロイ環境はVercelを想定しています。VercelではRemixはAWS Lambdaの上で動作するためローカルサーバーや他の環境とは少し異なる方法が必要となります。Vercel社がNext.js上でOG画像を生成するサンプルコードを掲載しているのでそちらを参考にRemixへ組み込んでみました。

実装の流れ

RemixではResource Routesという機能があり、通常のルート上にhtmlを出力するページではなく何らかのResponseを返す関数を配置できます。Next.jsで例えるとapiルートの中で利用できる機能がpagesディレクトリ内でも実行ができるというのに近いでしょうか。そのURLを開くことでhtmlページを開くのではなくPDFファイルを生成して返したり画像ファイルを生成して返したりといった動作が可能です。

今回はResource Routesを利用してOG画像とするスクリーンショット画像を生成し、png画像として返すという仕組みを作成しました。

流れとしては、以下のようになります。

  • microCMSの投稿IDを含んだResource Routesファイルにアクセスする
    ex) https://example.com/ogimages/[postId].png
  • Resource Routes内でPuppeteerからテンプレートファイルを表示
  • テンプレートファイルではpostIdを使ってmicroCMSからタイトル名を取得しOG画像のもとになるhtmlファイルを生成
  • Puppeteerがテンプレートファイルのスクリーンショットを撮影しpng画像へ変換
  • Resource Routesからpng画像を返す

microCMSの設定

今回、microCMS上で記事にOG画像を登録できるようにAPIスキーマを変更し、画像が登録されている場合は登録画像をOG画像として利用。登録されていない場合に自動生成するという仕様にしてみます。coverというフィールドIDで追加しました。

あわせて型情報にも追記しておきます。

app/types/index.ts

// ...

export type Content = {
  id: string;
  title: string;
  image: MicroCMSImage;
  body: string;
  cover?: MicroCMSImage;
} & MicroCMSDate;

OG画像生成機能の実装

PuppeteerとLambda上で動作できるよう軽量化されたchromiumをインストールします。

$ npm install chrome-aws-lambda puppeteer-core

ResourceRoutesの作成

Resource Routesで拡張子を利用する場合にはRemixの仕様上.をエスケープする必要があるため拡張子部分を[]でエスケープする必要があります。今回$postID.pngというファイルを返したいため、[.png]部分をエスケープするようにしています。[.]pngというように.のみでも問題ありません。

app/routes/ogimages/$postId[.png].ts

import chromium from 'chrome-aws-lambda';
import { json, type LoaderFunction } from 'remix';

// Vercel環境(aws環境)の判定
const isDev = !process.env.AWS_REGION;

export const loader: LoaderFunction = async ({
  request,
}): Promise<Response> => {
  // headerを生成
  const headers: HeadersInit = {
    'Content-Type': 'image/png',
    'Content-Disposition': `inline; filename="ogp.png"`,
    'X-Content-Type-Options': 'nosniff',
    'Cache-Control':
      'public, immutable, no-transform, s-maxage=31536000, max-age=31536000',
  };

  let browser = null;
  let screenshot = null;

  try {
    // chromiumの設定をローカル環境とVercel環境で切り替え
    browser = await chromium.puppeteer.launch({
      args: isDev ? [] : chromium.args,
      channel: isDev ? 'chrome' : undefined,
      executablePath: isDev ? undefined : await chromium.executablePath,
      headless: isDev ? true : chromium.headless,
      defaultViewport: { width: 1200, height: 630 },
    });

    const page = await browser.newPage();

    // 同じディレクトリの$postIdページを撮影用のテンプレートファイルとして利用
    const templateUrl = request.url.replace(`.png`, '');
    // 画像、Webフォントを利用しているため通信が終わり500ms待つ
    await page.goto(templateUrl, { waitUntil: 'networkidle0' });

    // png画像としてスクリーンショットを撮影
    screenshot = await page.screenshot({ type: 'png' });
  } catch (error: unknown) {
    if (error instanceof Error) {
      throw json({ error: error.message }, 500);
    }
    throw new Error('Error creating the screenshot');
  } finally {
    if (browser) {
      await browser.close();
    }
  }

  if (typeof screenshot === 'undefined') {
    throw json({ error: 'Error creating the image' }, 500);
  }

  // スクリーンショット画像をレスポンスとして返す
  return new Response(screenshot, { headers });
};

スクリーンショット用テンプレートファイルの作成

次にスクリーンショットを撮影するためのテンプレートページを作成します。今回アバター画像やID名などは直接記載しています。

app/routes/ogimages/$postId.tsx

import { json, LinksFunction, LoaderFunction, useLoaderData } from 'remix';
import { client } from '~/libs/client.server';
import type { Content } from '~/types';
import styles from '~/styles/ogimages.css';

type LoaderData = {
  title: Content['title'];
};

// Lambda上のchromiumでは日本語フォント、絵文字がないためWebフォントを利用
export const links: LinksFunction = () => {
  return [{ rel: 'stylesheet', href: styles }];
};

export const loader: LoaderFunction = async ({ params }) => {
  const postId = params.postId;

  // microCMS APIからタイトルのみ取得
  const content = await client
    .get<Content['title']>({
      endpoint: 'blog',
      contentId: postId,
      queries: {
        fields: 'title',
      },
    })
    // 記事が404の場合は404ページへリダイレクト
    .catch(() => {
      throw new Response('Content Not Found.', {
        status: 404,
      });
    });

  return json(content);
};

export default function OgImage(): JSX.Element {
  const { title } = useLoaderData<LoaderData>();

  return (
    <div id="ogimage" className="w-[1200px] h-[630px]">
      <div className="flex flex-col justify-between items-center p-12 space-y-12 h-full text-white bg-gradient-to-b from-cyan-800 to-blue-900">
        <div className="flex items-center w-full h-full">
          <h1 className="text-7xl leading-[1.2]">{title || 'no title'}</h1>
        </div>
        <div className="flex justify-end items-center mt-auto w-full">
          {/* アバター画像、IDはいったん直接記載 */}
          <img
            src="https://github.com/himorishige.png"
            alt="himorishige"
            className="w-24 h-24 rounded-full"
          />
          <p className="pl-4 text-6xl text-right">@_himorishige</p>
        </div>
      </div>
    </div>
  );
}

VercelのLambda上のchromiumでは日本語フォント、絵文字フォントを持っていないため日本語や絵文字を表示できません。そのためcssファイルを別途用意してWebフォントを利用できるようにしておきます。

app/styles/ogimages.css

@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+JP&display=swap');
@font-face {
  font-family: 'NotoColorEmoji';
  src: url('https://raw.githack.com/googlei18n/noto-emoji/master/fonts/NotoColorEmoji.ttf')
    format('truetype');
}
#ogimage {
  font-family: 'Noto Sans JP', sans-serif, 'NotoColorEmoji';
}

記事詳細ページからOG画像を読み込む

記事詳細ページで自動生成したOG画像を読み込むよう変更します。microCMSでOG画像が登録してある場合はそちらを利用するようにします。今回metaタグ内に記載するデータは直接記載していますが、実際の運用時には別に管理したものを出力したり、microCMSで管理している値を出力したりといった方法がよさそうです。

app/routes/posts/$postId.tsx

// ...

type LoaderData = {
  content: Content;
  domain: string;
};

// ...

// MetaFunctionにはLoaderFunctionで取得したdataの他にparamやlocationを受け取ることができる
// https://remix.run/docs/en/v1/api/conventions#page-context-in-meta-function
export const meta: MetaFunction = ({ params, location, data }) => {
  const { content, domain }: LoaderData = data ?? {};

  const url = `${domain}${location.pathname}`;
  const image = content?.cover
    ? content.cover.url
    : `${domain}/ogimages/${params.postId}.png`;

  // それ以外のmeta情報をよしなに
  return {
    title: `${content?.title}`,
    description: 'description',
    'og:url': url,
    'og:title': `${content?.title}`,
    'og:description': 'description',
    'og:image': image,
    'og:site_name': 'サイト名',
    'twitter:card': image ? 'summary_large_image' : 'summary',
    'twitter:creator': '@_himorishige',
    'twitter:site': '@_himorishige',
  };
};

// Headersからホスト名を取得する
const getHostname = (headers: Headers): string => {
  const host = headers.get('X-Forwarded-Host') ?? headers.get('host');
  if (!host) {
    throw new Error('Could not determine domain URL.');
  }
  const protocol = host.includes('localhost') ? 'http' : 'https';
  const domain = `${protocol}://${host}`;
  return domain;
};

// microCMS APIから記事詳細を取得する
export const loader: LoaderFunction = async ({ params, request }) => {
  // Vercel環境ではデプロイごとに複数の名称ができるためドメイン名を取得する。
  // 定数で管理できる環境の場合は不要。
  const domain = getHostname(request.headers);

  // 下書きの場合
  const url = new URL(request.url);
  const draftKey = url.searchParams.get('draftKey');

  const content = await client
    .get<Content>({
      endpoint: 'blog',
      contentId: params.postId,
      queries: { draftKey: draftKey ?? '' },
    })
    .catch(() => {
      // 記事が404の場合は404ページへリダイレクト
      throw new Response('Content Not Found.', {
        status: 404,
      });
    });

  // 下書きの場合キャッシュヘッダを変更
  const headers = draftKey
    ? { 'Cache-Control': 'no-store, max-age=0' }
    : undefined;

  return json({ content, domain }, { headers });
};

// ...

動作確認

これで実装が完了しました。ローカル環境で動作を確認してみます。

$ npm run dev

ローカルサーバーが問題なく起動できたら記事詳細ページを開いてOG画像が正しく設定されているか確認します。またそのURLを直接開いてOG画像が生成されているか確認してみます。

デプロイ

ローカル環境で動作が確認できたところでVercelにデプロイしてOG画像が生成されるかを確認していきます。

さらにTwitter Card Validatorでも確認してみます。Card URLのフィールドにVercelでデプロイしている記事のURLを入力してPreview cardボタンをクリックします。反映された情報が出るまで少し時間がかかる場合があるため何度が押下する必要があるかもしれません。

日本語フォントや絵文字も問題なく表示することができました。

さいごに

無事にRemixを使ってOG画像を自動生成することができました。デザインはまだ何もできていませんが機能は徐々にブログらしくなってきました。今回はスクリーンショット版を作成するのと同様にcanvasを利用したものも作成してみましたが動作自体は速く感じたものの、デザインの変更などスクリーンショットを利用する方が運用が楽だと感じ最終的にスクリーンショット版を採用しています。

ソースコード

下記に今回実装したものを公開していますのでうまく動作を試す場合など参考にしていただければと思います。記事内ではエラーハンドリングやバリデートなどの一部処理を省略していますので一部記載が異なる箇所がある旨ご了承ください。