0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SPA(Single Page Application)でも動的OGPを実現する — CloudFront Functions + Lambda アーキテクチャ

0
Last updated at Posted at 2026-05-10

はじめに

LINEやTwitter(X)でリンクをシェアしたとき、ページの画像やタイトルが表示されるあの機能。OGP(Open Graph Protocol) です。

SPA(SinglePageApplication)で作ったWebアプリを「ちゃんとOGPが出るようにしたい」と思ったとき、壁にぶつかります。

気に入った旅のレポートを友達に共有したいとき

本当は下のように表示させたい。

image.png

ReactなどのSPAの場合、これができない。なぜなら、

「クローラーはJavaScriptを実行しないから。」

react-helmet-asyncvue-meta でOGPタグを動的に設定していても、SNSクローラーは index.html の生の中身しか見ない。結果として全ページ同じ汎用サムネイルが表示され、シェアの訴求力がゼロになります。

この記事では、SPAを大きく書き換えることなく、CloudFront Functions と Lambda を組み合わせて動的OGPを実現するアーキテクチャを解説します。


なぜSPAではOGPが機能しないのか

まず問題を整理します。

通常のブラウザは index.html を受け取った後、JavaScriptを実行して og:titleog:image を動的に書き込みます。しかしLINEやTwitter(X)、FacebookのクローラーはJavaScriptを実行しません。受け取った生の HTMLをそのまま解析するため、SPAが動的に設定したOGPタグは無視されます。

各SNSクローラーのJavaScript実行有無

SNS クローラー UA JS実行
Google検索 Googlebot ✅(遅延あり)
Twitter(X) Twitterbot
LINE facebookexternalhit
Facebook 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つです。

  1. 通常ユーザーへの影響ゼロ: CloudFront Functionsは通常ブラウザのリクエストをそのままSPAへ通す
  2. クローラーだけを分岐: UA判定でクローラーのみを302リダイレクト
  3. 既存インフラをそのまま流用: 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) の実際の対応内容をベースにしています。

ローカル開発環境の構築やデプロイ自動化については、以下の記事もあわせてどうぞ。

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?