👏

Next13.3のApp RouterでOG画像のmetaタグを自動生成させる

2023/04/07に公開

この記事でやること

https://zenn.dev/temasaguru/articles/641a10cd5af02a

App Routerのメタタグのうち、OG画像を動的に設定する。

app
├── blog
│   ├── [id]
│   │   ├── page.tsx
│   │   └── opengraph-image.tsx

こういうルートを作って、

  <head>
    <title>テスト記事</title>
    <!-- 前略 -->
+   <meta property="og:image:alt" content="記事のアイキャッチ画像">
+   <meta property="og:image:type" content="image/png">
+   <meta property="og:image" content="http://localhost:3000/blog/test/opengraph-image?03ab5ca7bf5de3f9">
+   <meta property="og:image:width" content="1200">
+   <meta property="og:image:height" content="630">
    <!-- 後略 -->
  </head>

OG画像のmetaタグと中身をまとめて自動生成させる。このアプローチでは、OG画像のURLをコードに書く必要がなくなる。

OG画像の新しい設定方法

CMSと連動したOG画像について、以下のルートを作ってみるとする。

https://hogehoge.vercel.app/blog/1/opengraph-image

例えば /blog/[id]/opengraph-image で画像を返すルートを定義する。存在しない記事の画像は返さないため、改ざんする余地がなくなる。

content={`https://${アプリが動いているドメイン}/blog/${id}/opengraph-image`}

Next13.2以前にそういったアプローチを取るには、アプリ自らの絶対URLが必要だった。

(プレビューを考慮すると本当に面倒!なんでVERCEL_URLはドメインにならないんだよ!)

🤔 < 明らかにOG画像のルートがあるなら...

🤔 < metaタグが自動で付いたらいいのでは?

そんな理想を実現するのが、Next.js 13.3の 「ファイルベースメタデータルート」 である。

ファイルベースメタデータルートとは

https://zenn.dev/temasaguru/articles/641a10cd5af02a

Next.js 13.2で、App Routerの metadata 設定機能が新設された。

https://beta.nextjs.org/docs/api-reference/metadata#file-based-metadata

これに加え、Next.js 13.3では 「ファイルベースメタデータルート」 が新設された。

opengraph-image.(jpg|png|svg|ts|tsx)

今回は、そのうち opengraph-image というルートを使う。

拡張子を見ると分かる通り、opengraph-image.(jpg|png|svg)を置けば、それだけでOG画像になる。この記事では、そこを動的に設定してみる。

OG画像の実装手順

下準備

app
├── blog
│   ├── [id]
│   │   ├── page.tsx
│   │   └── opengraph-image.tsx
mkdir -p src/lib && touch src/lib/blog.ts
mkdir -p "src/app/blog/[id]"
for name in {layout,page,opengraph-image}; do touch "src/app/blog/[id]/$name.tsx"; done

/blog/[id] というルートで記事を表示するとする。

src/lib/blog.ts
interface Post {
  id: string;
  title: string;
}

// 以下、デモ用にNextのfetchを使っている

const headers: HeadersInit = {
  'X-MICROCMS-API-KEY': process.env.MICROCMS_API_KEY!,
};

export async function getPosts() {
  const response = await fetch(
    `https://${process.env.MICROCMS_SERVICE_NAME}.microcms.io/api/v1/blog`,
    { headers }
  );
  const data = await response.json();
  return data.contents as Post[];
}

export async function getPostById(id: string) {
  try {
    const response = await fetch(
      `https://${process.env.MICROCMS_SERVICE_NAME}.microcms.io/api/v1/blog/${id}`,
      { headers }
    );
    return (await response.json()) as Post;
  } catch (e) {
    console.error(e);
    return null;
  }
}

とりあえずmicroCMSをfetchしている。

個別記事ページ

src/app/blog/[id]/layout.tsx
export const revalidate = 10;

export default function BlogPostLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return <article>{children}</article>;
}

検証用にキャッシュ有効期限を短くしている。

src/app/blog/[id]/page.tsx
import { getPostById, getPosts } from '@/lib/blog';
import { notFound } from 'next/navigation';

export async function generateStaticParams() {
  const posts = await getPosts();
  return posts.map(({ id }) => ({
    id,
  }));
}

type Props = {
  params: { id: string };
};

export async function generateMetadata({ params: { id } }: Props) {
  const post = await getPostById(id);
  if (post) {
    return {
      title: post.title,
    };
  }
  return {};
}

export default async function BlogPostPage({ params: { id } }: Props) {
  const post = await getPostById(id);
  if (!post) notFound();
  return <div>{post.title}</div>;
}

この手法では、記事ページにOG画像のURLを設定する必要がない。

今までは「絶対URLを付けなきゃ」とか「動的にパラメータを付けなきゃ」とか色々考慮する必要があったが、この方法ならページだけに集中できる。

フォントファイルの読み込みについて

src/lib/font.ts
/**
 * Google FontsのCSSファイルから、
 * フォントURL `src: url(ここ) format(truetype OR opentype)` を探し、
 * 見つかればfetchしてArrayBufferにして返す
 * @see https://github.com/kvnang/workers-og/blob/main/packages/workers-og/src/font.ts
 * @see https://zenn.dev/uzimaru0000/articles/satori-workers
 *
 * 調べてみると、next/fontも同じようなことをしている
 * @see https://github.com/vercel/next.js/blob/canary/packages/font/src/google/find-font-files-in-css.ts
 */
export async function loadGoogleFont({
  family,
  weight,
  text,
}: {
  family: string;
  weight?: number;
  text?: string;
}) {
  const params = new URLSearchParams({
    family: `${family}${weight ? `:wght@${weight}` : ''}`,
  });
  if (text) {
    params.append('text', text);
  } else {
    params.append('subset', 'latin');
  }

  const url = `https://fonts.googleapis.com/css2?${params.toString()}`;

  const css = await fetch(url).then((res) => res.text());

  const fontUrl = css.match(
    /src: url\((.+)\) format\('(opentype|truetype)'\)/
  )?.[1];

  if (!fontUrl) {
    throw new Error('Font file not found in CSS fetched from Google Fonts');
  }

  return fetch(fontUrl).then((res) => res.arrayBuffer());
}

https://zenn.dev/uzimaru0000/articles/satori-workers

後述する理由により、フォントファイルのアップロードが難しい。そのため、上記記事で言及されていた方法を元に、Google FontsのCSSからフォント部分を抜き出してfetchしている。

「パワープレイ」だなと思ったが、next/fontにも似たような「CSSからフォントを探す」処理があるため、Google Fontsを外部から活用する上で仕方なく生じた処理なのだろう。

画像の生成

そして、opengraph-image.tsx の出番。

src/app/blog/[id]/opengraph-image.tsx
import { getPostById } from '@/lib/blog';
import { loadGoogleFont } from '@/lib/font';
import { ImageResponse } from 'next/server';

/** ImageResponse対応 */
export const runtime = 'edge';
/** 有効期間 */
export const revalidate = 10;

/** 13.3.0現在ここを動的にはできない */
export const alt = '記事のアイキャッチ画像';
export const size = {
  width: 1200,
  height: 630,
};
export const contentType = 'image/png';

type Props = {
  params: { id: string };
};

export default async function og({ params: { id } }: Props) {
  const notoSansArrayBuffer = await loadGoogleFont({
    family: 'Noto Sans JP',
    weight: 700,
  });

  const post = await getPostById(id);
  if (post) {
    return new ImageResponse(
      (
        <div
          style={{
            fontSize: 64,
            background: 'white',
            width: '100%',
            height: '100%',
            display: 'flex',
            alignItems: 'center',
            justifyContent: 'center',
          }}
        >
          {post.title}
        </div>
      ),
      {
        ...size,
        fonts: [
          {
            name: 'NotoSansJP',
            data: notoSansArrayBuffer,
            style: 'normal',
            weight: 700,
          },
        ],
      }
    );
  } else {
    return new Response('Not Found', { status: 404 });
  }
}

ImageResponse APIなので、エッジランタイムの指定が必要な点に注意。

アイキャッチの様子

上記のコードだと、こんな画像が出力される。デザイン方法に関してはSatoriのドキュメントを参照。

  <head>
    <title>テスト記事</title>
    <!-- 前略 -->
+   <meta property="og:image:alt" content="記事のアイキャッチ画像">
+   <meta property="og:image:type" content="image/png">
+   <meta property="og:image" content="http://localhost:3000/blog/test/opengraph-image?03ab5ca7bf5de3f9">
+   <meta property="og:image:width" content="1200">
+   <meta property="og:image:height" content="630">
    <!-- 後略 -->
  </head>

そして、このルートの何が嬉しいかというと、該当ページのmetaタグを同時に生成してくれる。これにはalt width height も含まれる。コード冒頭でsizeを定義しているのは、metaタグとして認識させるため。

revalidateにも対応している

OG画像はエッジで動作しているが、revalidateの設定がないと永久にキャッシュが表示され、更新されなくなってしまう。

(revalidateしても記事ページに付くクエリパラメータは変わらない? それだとOG画像だけ前のままになるんじゃないか? 要調査)

余談: Edge Functionsの制限VS漢字表現の自由

https://zenn.dev/hiromu617/articles/c03fef6f4d6c6e#カスタムフォントを設定する

実装を試みた当初は、フォントファイルをバンドルしていた。

上記の記事では常用漢字をカバーする方針になっているが、そのままだとNoto Sans JP Boldが513KBになり、Functionのサイズは1.12MBになった (23/4/9・v13.3.0現在)。

無料プランのEdge Functionsには1MBの制限があり(ソース)、かなりサブセット範囲を削る必要性が出るため、フォントファイルのアップロードは現実的でない。 なおこの制限はCF Workers 無料プランを継承しているため、Vercel固有の問題ではない。

ちなみにCF Workersの有料プランは、5ドルで5MBまで使える。このためだけに契約するのはナンセンスだが、これを機会に色々なLambdaを載せ替えるのもありかもしれない。

Discussion