問題と対応のまとめ

開発を担当しているサービスでは、作品詳細ページにX(旧Twitter)のシェアボタンを設置しています。ボタンを押すと書影(表紙画像)付きのTwitter Cardとしてツイートに表示されることを想定していました。しかしシェアボタンの動作確認中に実際にシェアしてみたところ、書影が表示されずテキストのみのカードになっていることを発見しました。

当初のシェアボタンは作品詳細ページのURLを直接参照しており、書影はページを開いた後にJavaScriptが内部APIを呼び出して動的に取得・表示する実装でした。

原因はこの構成にあります。フロントエンドはNext.jsの静的エクスポートで動いているため、初回のHTMLレスポンスには書影が含まれていません。Twitter Cardはページの静的HTMLに埋め込まれたメタタグを読み取る仕組みのため、CSR(クライアントサイドレンダリング)で取得した書影をOGPタグとして渡せません。

SSR(サーバーサイドレンダリング)への移行も検討しましたが、ホスティング構成の大幅な変更が必要なため見送りました。詳しくは後述の「なぜ専用エンドポイントを追加したか」で説明します。

解決策として、api-frontend にTwitterbot向けのOGP生成エンドポイントを新設しました。フロントエンドのXシェアURLをそこへ向けることで、書影付きのTwitter Cardが表示されるようになりました。この対応は調査・実装・動作確認を含めて約2週間で完了しました。


OGP と Twitter Card について

OGP(Open Graph Protocol) とは、Webページの情報をSNSやメッセージアプリが正しく読み取れるよう構造化するための仕様です(ogp.me)。OGPタグを設定していない場合、SNSでシェアしてもURLがそのままテキストとして表示されます。HTMLの <head>og:titleog:descriptionog:image などのタグを記述します。これにより、URLシェア時にタイトル・説明・画像のプレビューが自動生成されます。Facebookが策定し、現在は多くのSNS・チャットツールで対応しています。

Twitter Card はX(旧Twitter)が提供するURLシェア時のリッチリンクプレビュー機能です。OGPタグをベースに twitter:cardtwitter:titletwitter:image などの専用タグを加えることで、ツイート内に画像・タイトル・説明文を含むカードが生成されます。


サービスの構成

このサービスのフロントエンドは2つのリポジトリに分かれています。ユーザー向け画面を担当する frontend と、決済・外部連携など動的なサーバー処理が必要なAPIを担当する api-frontend です。

本記事の実装は api-frontend の以下の環境で行いました。

技術バージョン
Next.js13.4.12
TypeScript5.1.6
graphql-request6.1.0
Node.js20.x

frontend はNext.jsの静的エクスポートでHTML・CSS・JSをCDNで配信しています。書影の画像ファイルも別のCDN上に置かれており、Next.jsのコードには含まれていません。

api-frontend は決済・外部連携を担うAPIルートをホスティングしているNext.jsアプリです。サーバーサイドで動作するため、リクエスト時に動的な処理が可能です。

OGPエンドポイントはリクエスト時に内部APIから書影データを動的に取得する必要があります。静的エクスポートの frontend はビルド済みファイルを配信するだけでリクエスト時にコードを実行できないため、api-frontend に実装します。

対応前後のリクエスト経路の違いを示します。

【対応前:通常ユーザー・Twitterbot 共通】

シェア URL ─→ frontend(CDN)─→ HTML 配信(OGP タグに書影なし)
                                    ↑
                        Twitterbot はここで OGP を読む → 書影なし

【対応後:User-Agent で処理を分岐】

シェア URL ─→ api-frontend /api/ogp/titles/{slug}
                  ├─ 一般 UA ────→ 302 ─→ /titles/{slug}/detail/(frontend)
                  └─ Twitterbot ─→ 内部 GraphQL API で書影データ取得
                                   ─→ CDN URL + origin_key で og:image 組み立て
                                   ─→ OGP タグ入り HTML を 200 で返す

静的エクスポートと Twitterbot の相性問題

静的エクスポートとは

Next.jsには主に2つの出力方式があります。

静的エクスポート(next export、現在は output: 'export' は、ビルド時にHTML・CSS・JSファイルのみを生成する方式です。サーバーを必要とせずCDNでファイルをそのまま配信できるため、パフォーマンスとホスティングコストの面で有利です。ただし、ページの内容はブラウザ上でJavaScriptが実行されてから表示されます(CSR)。書影などの動的データもJS実行後にAPIから取得するため、初回のHTMLレスポンスには含まれていません。

SSR(Server-Side Rendering) はリクエストごとにサーバーが処理を行い、データ取得済みの状態でHTMLを返す方式です。初回レスポンスのHTMLに書影情報を含められるため、OGPタグにも書影を埋め込めます。

Twitterbot との相性問題

frontend は静的エクスポートを採用しています。Twitterbotはブラウザではなく、OGPメタタグを読む目的に特化したシンプルなHTTPクライアントです。Twitter Cardは静的HTMLに埋め込まれたメタタグを読み取る仕組みのため、CSRが完了する前の状態のOGPタグ、つまり書影が埋め込まれていないデフォルト状態のタグしか受け取れません。

なぜ専用エンドポイントを追加したか

SSRに変更すれば各リクエスト時にサーバーで書影を取得してOGPタグに埋め込めるため、この問題は根本的に解決できます。しかし frontend は静的エクスポートを前提にホスティング構成が組まれており、SSR化するには、現在の構成を大きく変える必要があります。現状は静的ファイルをCDNに置くだけのシンプルな構成ですが、SSRではリクエストのたびにコードを実行するNode.jsサーバーを常時稼働させる必要があります。サーバーの準備・管理、「ファイルをアップロードする」から「サーバーをビルドして起動する」へのデプロイ手順の刷新、そしてCDN配信と比べて高くなるホスティング費用の増加が伴います。書影のOGP対応という限定的な目的のために全体の構成を変えるのはコストが見合わないため、サーバーサイドで動作する api-frontend に専用エンドポイントを追加する方式を選択しました。


OGP エンドポイントの設計

エンドポイントのURLは次のとおりです。

https://example.com/api/ogp/titles/{slug}

リクエストのUser-Agentを確認し、Twitterbotかどうかで処理を分岐します。

  • Twitterbot 以外:作品詳細ページ(/titles/{slug}/detail/)へ302リダイレクト
  • Twitterbot:書影・タイトル・著者を含むOGPタグ入りのHTMLを200で返す

Twitterbot以外がこのURLを踏んだ場合もフロントエンドへ転送されるため、XのシェアボタンURLをこのエンドポイントに変更しても一般ユーザーの操作に影響はありません。


実装の概要

実装は以下の4ステップで構成されています。

  1. GraphQLスキーマとクエリを追加する(api-frontend
  2. GraphQLクライアント関数を作る(api-frontend
  3. OGP生成エンドポイントを実装する(api-frontend
  4. フロントエンドのシェアURLを変更する(frontend

ステップ1〜3が api-frontend リポジトリの変更、ステップ4が frontend リポジトリの変更です。それぞれ詳しく説明します。


ステップ 1:GraphQL スキーマとクエリを追加する

書影と著者情報を取得するために、GraphQLスキーマを新規作成します。

graphql/schemas/title.graphqls:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
type TitleCover {
  origin_key: String!
}

type Title {
  name: String!
  authors: [Author!]
  description: String
  title_cover: TitleCover
}

input TableTitleFilterInput {
  slug: TableStringFilterInput
}

extend type Query {
  getTitle(input: TableTitleFilterInput!): Title
}

TableStringFilterInputschema.graphqls に共通型として定義済みのため再定義は不要です。Author 型は graphql/schemas/author.graphqls に定義しています。

続いてOGP取得専用のクエリを作成します。

graphql/documents/query/get_title_for_ogp.graphql:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
query GetTitleForOgp($slug: String!) {
  getTitle(input: { slug: { eq: $slug } }) {
    name
    authors {
      name
    }
    description
    title_cover {
      origin_key
    }
  }
}

npm run codegen を実行すると、src/repositories/graphql.ts に型と関数が自動生成されます。生成されるのは GetTitleForOgpQueryGetTitleForOgpQueryVariablesgetSdk().GetTitleForOgp の3つです。


ステップ 2:GraphQL クライアント関数を作る

src/lib/graphql-client/get-title-for-ogp.ts を新規作成します。既存のGraphQLクライアント関数のパターンを踏襲していますが、OGPは公開データのため認証ヘッダーは不要です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import { getSdk } from '@/repositories/graphql';
import { GraphQLClient } from 'graphql-request';

export const getTitleForOgp = async (slug: string) => {
  const endpoint = process.env.SERVICE_API_URL_INTERNAL || '';
  const client = new GraphQLClient(endpoint);

  try {
    const res = await getSdk(client).GetTitleForOgp({ slug });
    return res.getTitle ?? undefined;
  } catch (err) {
    console.error(
      `[getTitleForOgp] error for slug=${slug}: ${
        err instanceof Error ? err.message : 'unknown error'
      }`,
    );
    return undefined;
  }
};

エラー時は undefined を返し、呼び出し側でフォールバック処理を担います。GraphQL APIが応答しない場合もクラッシュせず、呼び出し元が状況に応じた処理を選択できます。


ステップ 3:OGP 生成エンドポイントを実装する

src/pages/api/ogp/titles/[slug].ts を新規作成します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
import { getTitleForOgp } from '@/lib/graphql-client/get-title-for-ogp';
import type { NextApiRequest, NextApiResponse } from 'next';

const isBotUserAgent = (ua: string): boolean => ua.includes('Twitterbot');

const escapeHtml = (str: string): string =>
  str
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#x27;');

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse,
) {
  const { slug } = req.query;
  if (typeof slug !== 'string') return res.status(400).end();

  const frontendUrl = process.env.FRONTEND_URL ?? '';
  const detailUrl = `${frontendUrl}/titles/${slug}/detail/`;
  const userAgent = req.headers['user-agent'] ?? '';
  console.log(`[ogp] slug=${slug} UA=${userAgent}`);

  if (!isBotUserAgent(userAgent)) {
    return res.redirect(302, detailUrl);
  }

  const title = await getTitleForOgp(slug);
  if (!title?.title_cover) return res.redirect(302, detailUrl);

  const escapedImage = escapeHtml(
    `${process.env.CDN_URL ?? ''}/${title.title_cover.origin_key}`,
  );
  // CDN_URL は末尾スラッシュなしで設定すること(例: https://cdn.example.com)
  const escapedName = escapeHtml(title.name);
  const escapedAuthor = escapeHtml(
    title.authors?.map((a) => a.name).join('・') ?? '',
  );
  const escapedDetailUrl = escapeHtml(detailUrl);
  const siteDescription = 'サービスのキャッチコピー・説明文が入ります。';

  const html = `<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8" />
  <title>${escapedName} | サービス名</title>
  <meta property="og:title" content="${escapedName} - ${escapedAuthor} | サービス名" />
  <meta property="og:description" content="${siteDescription}" />
  <meta property="og:image" content="${escapedImage}" />
  <meta property="og:url" content="${escapedDetailUrl}" />
  <meta property="og:type" content="book" />
  <meta property="og:site_name" content="サービス名" />
  <meta name="twitter:card" content="summary_large_image" />
  <meta name="twitter:title" content="${escapedName} - ${escapedAuthor} | サービス名" />
  <meta name="twitter:description" content="${siteDescription}" />
  <meta name="twitter:image" content="${escapedImage}" />
  <meta name="twitter:site" content="@サービスのXアカウント" />
</head>
</html>`;

  return res
    .setHeader('Content-Type', 'text/html; charset=utf-8')
    .setHeader('Cache-Control', 'no-store')
    .status(200)
    .send(html);
}

実装のポイントをいくつか補足します。

typeof slug !== 'string' による入力検証:Next.jsの req.querystring | string[] 型です。URLに同一キーが複数ある場合(例:?slug=a&slug=b)は配列になるため、string に絞り込んでいます。

書影なし・API障害時の302:書影が取得できない場合は作品詳細ページへ302リダイレクトします。frontend の静的HTMLにはビルド時にサービス共通のデフォルトOGP画像が設定されているため、書影なし作品でもカードが生成されます。

escapeHtml による XSS 防止:HTMLテンプレートの属性値に展開する変数はすべてエスケープし、escaped プレフィックスで明示しています。slug はユーザー入力なので、" を含む値で属性を脱出しタグ注入が成立し得ます。エスケープ対象は &<>"' でOWASPのHTML属性エスケープルールに準拠しています。このテンプレートでは属性値を "..." で囲んでいるため ' の実害はありませんが、ルールに揃えておくことで他の文脈にコードを流用した際のリスクを減らせます。detailUrl はリダイレクト先URLとしても使うため、HTML用には escapeHtml を適用した escapedDetailUrl を別途用意しています。

Cache-Control: no-store:中間プロキシやCDNでのキャッシュを防ぐために設定しています。

og:titletwitter:title の統一${name} - ${author} | サービス名 の形式で著者名を含めています。作品名だけでは他のシェアとの区別がつきにくく、著者名を入れることでツイートに情報量が増します。


ステップ 4:フロントエンドのシェア URL を変更する

frontendsrc/pages/titles/[slug]/detail/component.tsx 内の getShareUrl 関数を修正します。

1
2
3
4
5
// 変更前
const url = `${process.env.NEXT_PUBLIC_FRONTEND_URL}/titles/${v.slug}/detail/`;

// 変更後
const url = `${process.env.NEXT_PUBLIC_FRONTEND_URL}/api/ogp/titles/${v.slug}/`;

NEXT_PUBLIC_FRONTEND_URL はフロントエンドドメインを指す環境変数です。api-frontend と同一ドメインで動作しているため、この変数をそのまま使えます。

以上で実装は完了です。次に、Twitterbotの目線から実際の動作を確認します。


curl でボットの視点を再現する

OGP系のトラブルはブラウザでは気づきにくいです。curl にTwitterbotのUAを指定することで、ボットが実際に受け取っているレスポンスを確認できます。

-L(リダイレクト追従)フラグをつけないことが重要です。 -L をつけると302の存在が隠れて、問題に気づけません。

1
2
3
4
5
# Twitterbot として叩く(-L なし)
curl -v -A "Twitterbot/1.0" https://example.com/api/ogp/titles/<slug>

# 一般 UA(302 リダイレクトを確認)
curl -v https://example.com/api/ogp/titles/<slug>

レスポンスの見方は次のとおりです。

レスポンス意味
HTTP/2 200 + <html>OGP タグの中身を確認(og:image の URL が正しいか)
HTTP/2 302Twitterbot に OGP が渡せていない → 環境変数・内部 API を確認

検証

curlで動作確認します。

1
2
3
4
5
# 書影あり:200 + og:image タグを確認
curl -A "Twitterbot/1.0" https://example.com/api/ogp/titles/<slug>

# 一般 UA:302 リダイレクトを確認
curl -v https://example.com/api/ogp/titles/<slug>

200が返り、レスポンスのHTMLに og:image タグとCDNのURLが含まれていることを確認してください。


まとめ

  • Next.jsの静的エクスポートを採用し書影をCSRで取得する設計では、Twitterbotに書影入りのOGPを渡せない。サーバーサイドで動くAPIにOGPエンドポイントを置き、UAで処理を分岐する設計が有効だ。なお本実装はTwitterbotのみ対応しており、facebookexternalhit・Slackbot・LINEなど他のボットは302で作品詳細ページへ転送される
  • OGPデバッグは curl -v -A "Twitterbot/1.0"-L なし)が最速の起点になる
  • CDNホスティングの画像をOGPに使う構成では、内部APIとCDN URLの両方が揃わないと書影は表示されない
  • 書影なし作品やAPI障害時は302で作品詳細ページへリダイレクトする。frontend の静的HTMLにはデフォルトOGP画像が設定されているため、そのままカードが生成される
  • 著者名入りの twitter:title を設定することで、ツイート上での情報量を増やせる

参考