はじめに
LINEやTwitter(X)でリンクをシェアしたとき、ページの画像やタイトルが表示されるあの機能。OGP(Open Graph Protocol) です。
SPA(SinglePageApplication)で作ったWebアプリを「ちゃんとOGPが出るようにしたい」と思ったとき、壁にぶつかります。
気に入った旅のレポートを友達に共有したいとき
本当は下のように表示させたい。
ReactなどのSPAの場合、これができない。なぜなら、
「クローラーはJavaScriptを実行しないから。」
react-helmet-async や vue-meta でOGPタグを動的に設定していても、SNSクローラーは index.html の生の中身しか見ない。結果として全ページ同じ汎用サムネイルが表示され、シェアの訴求力がゼロになります。
この記事では、SPAを大きく書き換えることなく、CloudFront Functions と Lambda を組み合わせて動的OGPを実現するアーキテクチャを解説します。
なぜSPAではOGPが機能しないのか
まず問題を整理します。
通常のブラウザは index.html を受け取った後、JavaScriptを実行して og:title や og:image を動的に書き込みます。しかしLINEやTwitter(X)、FacebookのクローラーはJavaScriptを実行しません。受け取った生の HTMLをそのまま解析するため、SPAが動的に設定したOGPタグは無視されます。
各SNSクローラーのJavaScript実行有無
| SNS | クローラー UA | JS実行 |
|---|---|---|
| Google検索 | Googlebot | ✅(遅延あり) |
| Twitter(X) | Twitterbot | ❌ |
| LINE | facebookexternalhit | ❌ |
| facebookexternalhit | ❌ | |
| Slack | Slackbot-LinkExpanding | ❌ |
GoogleはJavaScriptを実行できるため SEO 上の問題は起きにくいですが、SNSシェアに関してはほぼすべてのクローラーがJSを実行しないと考えてよいです。
解決策の選択肢
SPA の OGP 問題への対応策は主に4つあります。
| 解決策 | 概要 | コスト | 難易度 | 外部依存 |
|---|---|---|---|---|
| CloudFront Functions + Lambda | クローラーUAを検出しOGP専用Lambdaにリダイレクト | ほぼ無料 | 中 | なし |
| Lambda@Edge | CloudFrontエッジでOGP HTMLを生成して返す | ほぼ無料 | 高 | なし |
| Prerender.io などのSaaS | クローラーをプリレンダリングサービスにプロキシ | $19〜/月 | 低 | あり |
| Next.js などSSRへ移行 | フレームワークごとSSR対応に切り替え | 大工数 | 高 | なし |
この記事では CloudFront Functions + Lambda を採用します。Lambda@Edgeより実装が簡単で、Prerender.ioより安く外部依存がなく、Next.js移行より工数が少ないためです。
Lambda@Edge を選ばない理由
Lambda@Edgeはよく紹介される手法ですが、開発・運用コストが高いという問題があります。
| 観点 | Lambda@Edge | CloudFront Functions + Lambda |
|---|---|---|
| デプロイリージョン | us-east-1 固定(本番インフラと別) | 既存リージョンのまま |
| 環境変数 | 使用不可(コードにハードコード) | 通常通り使用可能 |
| CloudWatchログ | 世界中のエッジに散在 | 既存リージョンに集約 |
| デバッグ | エッジ制約で困難 | 通常のLambdaと同じ |
| DBアクセス | エッジからVPCへの接続が複雑 | 既存VPC設定をそのまま使える |
CloudFront FunctionsはUA判定と302リダイレクトだけを担い、OGP HTMLの生成は既存のリージョンにある通常のLambdaに任せることで、Lambda@Edgeの制約をほぼすべて回避できます。
アーキテクチャ全体像
ポイントは3つです。
- 通常ユーザーへの影響ゼロ: CloudFront Functionsは通常ブラウザのリクエストをそのままSPAへ通す
- クローラーだけを分岐: UA判定でクローラーのみを302リダイレクト
- 既存インフラをそのまま流用: OGP LambdaはフロントのSPAと独立しており、既存のAPI Gatewayに追加するだけ
実装 Step 1: CloudFront Functions(UA判定 + リダイレクト)
CloudFront Functionsは、CloudFrontのエッジノードで実行できる軽量なJavaScript関数です。
特徴と制限:
| 項目 | 仕様 |
|---|---|
| ランタイム | JavaScript(ECMAScript 5.1 相当) |
| タイムアウト | 1ms(CPU時間) |
| 外部ネットワーク呼び出し | 不可 |
| 費用 | 月2,000万リクエストまで無料、超過は $0.10 / 100万 |
| デプロイ管理リージョン | us-east-1(SAM管理外) |
外部APIを呼び出せないため、UA判定と302リダイレクトのみを担います。OGP HTMLの生成は通常のLambdaに委ねます。
UA判定ロジック
// cloudfront-functions/ua-detector.js
// ランタイム: cloudfront-js-2.0(ECMAScript 5.1相当)
// ※ const/let/Arrow functions/fetch は使用不可
var CRAWLER_PATTERNS = [
'twitterbot',
'facebookexternalhit', // LINE と Facebook が共通で使用
'slackbot-linkexpanding',
];
var OGP_API_BASE = 'https://api.example.com'; // 自分のAPIエンドポイント
function isCrawler(ua) {
var lower = (ua || '').toLowerCase();
for (var i = 0; i < CRAWLER_PATTERNS.length; i++) {
if (lower.indexOf(CRAWLER_PATTERNS[i]) !== -1) return true;
}
return false;
}
function handler(event) {
var request = event.request;
var ua = (request.headers['user-agent'] || { value: '' }).value;
// 通常ユーザー: SPAホスティングへパススルー
if (!isCrawler(ua)) {
return request;
}
// /report/{id} 形式のパスからIDを抽出(パスパターンは自分のアプリに合わせる)
var match = request.uri.match(/^\/report\/([^/?#]+)/);
if (!match) return request;
var contentId = match[1];
// クローラー: OGP Lambdaに302リダイレクト
return {
statusCode: 302,
statusDescription: 'Found',
headers: {
location: { value: OGP_API_BASE + '/ogp/' + contentId },
'cache-control': { value: 'no-store' },
},
};
}
CloudFront Functionsのデプロイ
CloudFront FunctionsはSAMの管理対象外のため、AWS CLIで別途デプロイします。
# 関数の作成
aws cloudfront create-function \
--name ua-detector-prod \
--function-config Comment="OGP crawler UA detection",Runtime=cloudfront-js-2.0 \
--function-code fileb://cloudfront-functions/ua-detector.js \
--region us-east-1
# LIVE ステージに発行(作成後に返される ETag を使用)
aws cloudfront publish-function \
--name ua-detector-prod \
--if-match <ETag> \
--region us-east-1
CloudFront DistributionのCache Behaviorに関連付ける際は、SAMテンプレートで定義できます。
# template.yml(抜粋)
MyCloudFrontDistribution:
Type: AWS::CloudFront::Distribution
Properties:
DistributionConfig:
CacheBehaviors:
- PathPattern: "/report/*" # OGP対応するパスパターン
TargetOriginId: SpaOrigin
ViewerProtocolPolicy: redirect-to-https
CachePolicyId: 4135ea2d-6df8-44a3-9df3-4b5a84be39ad # CachingDisabled
FunctionAssociations:
- EventType: viewer-request
FunctionARN: !Sub "arn:aws:cloudfront::${AWS::AccountId}:function/ua-detector-prod"
DefaultCacheBehavior:
TargetOriginId: SpaOrigin
ViewerProtocolPolicy: redirect-to-https
CachePolicyId: 4135ea2d-6df8-44a3-9df3-4b5a84be39ad
実装 Step 2: OGP Lambda(HTMLを生成して返す)
OGP LambdaはAPIエンドポイント GET /ogp/{content_id} として既存のAPI Gatewayに追加します。DBからコンテンツ情報を取得し、OGPタグを含む最小限のHTMLを返します。
OGP HTMLテンプレート
# handlers/get_ogp.py
import html
OGP_HTML_TEMPLATE = """<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<title>{title}</title>
<meta property="og:type" content="article" />
<meta property="og:url" content="{original_url}" />
<meta property="og:title" content="{title}" />
<meta property="og:description" content="{description}" />
<meta property="og:image" content="{image_url}" />
<meta property="og:site_name" content="{site_name}" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="{title}" />
<meta name="twitter:description" content="{description}" />
<meta name="twitter:image" content="{image_url}" />
<meta name="robots" content="noindex" />
</head>
<body></body>
</html>"""
def escape(value: str) -> str:
"""DBから取得したユーザー入力値は必ずHTMLエスケープする"""
return html.escape(value or '', quote=True)
def build_ogp_html(content_id: str, row: dict, site_base_url: str,
default_image: str, site_name: str) -> str:
title = escape(row.get('title') or '') or 'コンテンツ'
description = escape(row.get('description') or '') or f'{site_name}のコンテンツです'
image_url = row.get('thumbnail_url') or default_image
return OGP_HTML_TEMPLATE.format(
title=title,
description=description,
image_url=image_url,
original_url=f"{site_base_url}/report/{content_id}", # 元のSPAのURL
site_name=site_name,
)
def build_404_html(default_image: str, site_name: str) -> str:
return OGP_HTML_TEMPLATE.format(
title='コンテンツが見つかりません',
description=f'{site_name}でコンテンツを探してみましょう',
image_url=default_image,
original_url='',
site_name=site_name,
)
Lambdaハンドラ
# lambda_function.py
import json
import os
from handlers.get_ogp import build_ogp_html, build_404_html
# database.py は自分の DB アクセスユーティリティに置き換えてください
from database import execute_statement
SITE_BASE_URL = os.environ['SITE_BASE_URL'] # 例: https://www.example.com
DEFAULT_IMAGE = os.environ['DEFAULT_OGP_IMAGE'] # フォールバック画像URL
SITE_NAME = os.environ['SITE_NAME'] # サービス名
HEADERS = {
'Content-Type': 'text/html; charset=utf-8',
'X-Robots-Tag': 'noindex', # OGP専用ページを検索インデックスさせない
}
def lambda_handler(event, context):
content_id = event.get('pathParameters', {}).get('content_id', '')
if not content_id:
return _response(400, build_404_html(DEFAULT_IMAGE, SITE_NAME), 'max-age=60')
try:
row = fetch_content(content_id)
except Exception:
# DBエラー時: フォールバックOGPを返す(5xxにしない)
return _response(200, build_404_html(DEFAULT_IMAGE, SITE_NAME), 'max-age=60')
if row is None or not row.get('is_public'):
return _response(404, build_404_html(DEFAULT_IMAGE, SITE_NAME), 'max-age=60')
html_body = build_ogp_html(content_id, row, SITE_BASE_URL, DEFAULT_IMAGE, SITE_NAME)
return _response(200, html_body, 'max-age=3600')
def fetch_content(content_id: str) -> dict | None:
sql = """
SELECT title, description, thumbnail_url, is_public
FROM contents
WHERE content_id = :content_id
"""
params = [{'name': 'content_id', 'value': {'stringValue': content_id}}]
rows = execute_statement(sql, params)
return rows[0] if rows else None
def _response(status: int, body: str, cache_control: str) -> dict:
return {
'statusCode': status,
'headers': {**HEADERS, 'Cache-Control': cache_control},
'body': body,
}
SAMテンプレートへの追加
既存のSAMテンプレートに数十行追加するだけで完了します。
# template.yml に追記
OgpFunction:
Type: AWS::Serverless::Function
Properties:
FunctionName: my-ogp-api
CodeUri: ogpApi/src/
Handler: lambda_function.lambda_handler
Runtime: python3.12
Timeout: 15
MemorySize: 256
Environment:
Variables:
SITE_BASE_URL: !Ref SiteBaseUrl
DEFAULT_OGP_IMAGE: !Sub "${SiteBaseUrl}/ogp-default.png"
SITE_NAME: "My App"
Events:
OgpGet:
Type: Api
Properties:
RestApiId: !Ref MyApi
Path: /ogp/{content_id}
Method: GET
実装のツボ — 見落としがちな3つのポイント
① og:url には元のSPAのURLを設定する
OGP LambdaのエンドポイントURLは api.example.com/ogp/123 のような形になります。しかし og:url にこのURLを設定してはいけません。
<!-- ❌ NG: APIのURLがSNSのリンク表示に使われる -->
<meta property="og:url" content="https://api.example.com/ogp/123" />
<!-- ✅ OK: 元のSPAのURLを設定する -->
<meta property="og:url" content="https://www.example.com/report/123" />
SNSはリンク展開時に og:url を正規URLとして扱います。APIのURLが設定されていると、SNS上でのリンク先がAPIエンドポイントになってしまいます。
② クローラーは302リダイレクトをフォローする
「リダイレクトするとOGPが読まれないのでは?」という心配は不要です。LINE / Twitter(X) / Facebook / Slackのクローラーはすべて302リダイレクトをフォローします。
| クローラー | 302フォロー |
|---|---|
| Twitterbot | ✅ |
| facebookexternalhit (LINE/Facebook) | ✅ |
| Slackbot-LinkExpanding | ✅ |
302リダイレクト先で正しいOGPタグが返れば、シェア時に正しく表示されます。
③ OGP専用ページはrobots noindexにする
OGP Lambdaが返すHTMLはクローラー向けのダミーページです。このページが検索エンジンにインデックスされると重複コンテンツ問題が発生します。
<meta name="robots" content="noindex" />
レスポンスヘッダーにも同様に設定しておくと確実です。
'X-Robots-Tag': 'noindex'
検証方法
curl でUA偽装してリダイレクトを確認
# Twitterbot として /report/123 にアクセス
curl -v -H "User-Agent: Twitterbot/1.0" https://www.example.com/report/123
# 期待されるレスポンス
# HTTP/2 302
# location: https://api.example.com/ogp/123
# cache-control: no-store
# -L でリダイレクトをフォローし、最終的なOGP HTMLを確認
curl -L -H "User-Agent: Twitterbot/1.0" https://www.example.com/report/123
# 期待されるレスポンス(OGP HTMLが返ってくれば成功)
# <meta property="og:title" content="..." />
# <meta property="og:image" content="..." />
CloudFront Functions のローカルテスト
# イベントファイルを用意
cat > events/crawler_request.json << 'EOF'
{
"version": "1.0",
"context": { "eventType": "viewer-request" },
"viewer": { "ip": "1.2.3.4" },
"request": {
"method": "GET",
"uri": "/report/123",
"headers": {
"user-agent": { "value": "Twitterbot/1.0" }
},
"querystring": {},
"cookies": {}
}
}
EOF
# CF Functions をテスト実行
aws cloudfront test-function \
--name ua-detector-prod \
--if-match <ETag> \
--event-object fileb://events/crawler_request.json \
--region us-east-1
各SNSの公式デバッガーで確認
実際にSNS側がOGPをどう解釈するかは公式ツールで確認します。
| SNS | ツール |
|---|---|
| Twitter(X) | https://cards-dev.twitter.com/validator |
| Facebook / LINE | https://developers.facebook.com/tools/debug/ |
| Slack | URLをSlackに貼って確認(ツールなし) |
運用上のTips
OGP画像のサイズ
Twitter(X) の summary_large_image は推奨サイズ 1200×630px(比率 1.91:1)。これ以外の比率だとトリミングされます。Facebookも同じ比率を推奨しています。
キャッシュ設計
OGP LambdaのレスポンスにCache-Controlを設定することで、SNS側がキャッシュした結果の鮮度を制御できます。
# コンテンツが更新されにくいなら長めに(1時間)
'Cache-Control': 'max-age=3600'
# 頻繁に更新されるなら短めに
'Cache-Control': 'max-age=300'
# エラー時は短く(すぐ再試行させる)
'Cache-Control': 'max-age=60'
なお、SNS側のキャッシュは別途管理されており、特にFacebookのデバッガーで「Scrape Again」を実行するとSNS側のキャッシュを強制更新できます。
UA偽装への対策
Twitterbot というUser-Agentは誰でも偽装できます。悪意のある大量アクセスへの対策として、API Gatewayのスロットリング設定を忘れずに入れておきましょう。
# SAMテンプレートのAPI設定に追加
MethodSettings:
- ResourcePath: "/ogp/*"
HttpMethod: GET
ThrottlingBurstLimit: 100
ThrottlingRateLimit: 50
まとめ
| コンポーネント | 役割 | 変更 |
|---|---|---|
| CloudFront Functions | UA判定 + 302リダイレクト | ★新規(数十行) |
| OGP Lambda | DBからコンテンツ取得 → OGP HTML返却 | ★新規 |
| SPA(既存) | 通常ユーザーへ index.html を返す | 変更なし |
| フロントエンドコード | react-helmet-async などの既存実装 | 変更なし |
このアーキテクチャの最大のメリットは「既存のSPAに手を入れずに済む」ことです。フロントエンドの改修なし、フレームワーク移行なし、外部SaaS不要。CloudFront Functionsは月2,000万リクエストまで無料で、OGP LambdaもAWS Lambdaの通常料金(実質無料に近い)で動きます。
「SPAでOGPが出ない」という問題は、よく見ると「クローラーだけ別の場所に誘導すればいい」というシンプルな問題です。Lambda@Edgeで全部やろうとするとus-east-1制約など複雑な問題に直面しますが、エッジはUA判定のみ、OGP生成は普通のLambdaという役割分担にすると、運用しやすい構成になります。
この記事の背景
このアーキテクチャは、グループ旅行のしおり管理・費用精算・旅行レポート共有ができる個人開発サービス 旅BASE(tabibase) の実際の対応内容をベースにしています。
ローカル開発環境の構築やデプロイ自動化については、以下の記事もあわせてどうぞ。
