SPA で構築すると気になるのは SEO と OGP です。SEO に関しては Google のクローラは JS を解釈してくれるようになったので fetch as Google で確認する限りインデックスされるようです(順位に影響があるかどうかは不明)。OGP に関しては現状、 facebook / twitter のクローラは JS を解釈してくれないので、サーバから返却される HTML に <meta property="og:title" content="title"> のようなタグを埋め込んでおく必要があります。
SPA OGP 対策として一番最初に語られるのが SSR (サーバサイドレンダリング) だと思いますが、OGP のためだけに SSR するのは面倒なので、Firebase Hosting + Functions を使ってなんとかして見る方法を試してみました。そのメモです。
注意
今回の対応方法は Firebase のみで構築をするパターンです。これには弱点が幾つかあります。
- リダイレクト処理が入るので、最初のレスポンスが微妙
- Google のインデックスがリダイレクトが入るため評価されるか微妙
これを回避するためには CloudFront + Lambda@edge を併用するパターンもあります。こちらも対応方法を書いたので参考にしてみてください。
Firebase + SPA + CloudFront + Lambda で SSR なしに OGP 対応
Hosting から Functions へ
Firebase Hosting では .firebase ファイルにルールを記載すると、特定 URL 時に Functions へ処理を受け渡すことができます。
{
  "hosting": {
    "rewrites": [
      {
        "source": "/@note/*",
        "function": "note"
      }
    ]
  }
}
この例だと /@note/piyo のような URL にアクセスすると note Function が起動します。
Functions で OGP 含む HTML
次にリクエストを受け取った note FUnction で、OGP のタグを含んだ HTML を返却するようにします。
import * as functions from 'firebase-functions';
import DocumentSnapshot = FirebaseFirestore.DocumentSnapshot;
import DocumentData = FirebaseFirestore.DocumentData;
import * as admin from 'firebase-admin';
admin.initializeApp(functions.config().firebase);
const db = admin.firestore()
function buildHtmlWithPost (id: string, noteObj:DocumentData) : string {
  return `<!DOCTYPE html><head>
  <title>${noteObj.title}</title>
  <meta property="og:title" content="${noteObj.title}">
  <meta property="og:image" content="${noteObj.image}">
  <meta property="og:image:width" content="600">
  <meta property="og:image:height" content="600">
  <meta name="twitter:card" content="summary">
  <meta name="twitter:title" content="${noteObj.title}">
  <meta name="twitter:image" content="${noteObj.image}">
  <link rel="canonical" href="/@note/${id}">
  </head><body>
  <script>window.location="/@note/?noteId=${id}";</script>
  </body></html>`
}
export const note = functions.https.onRequest(function(req, res) {
  const path = req.path.split('/')
  const noteId = path[2]
  db.collection('note').doc(noteId).get().then((doc:DocumentSnapshot) : void => {
    const htmlString = buildHtmlWithPost(noteId, doc.data())
    res.status(200).end(htmlString)
  }).catch(err => {
    res.status(500).end(err)
  })
})
この例では、URL に含まれている ID を元に firestore へデータを取得して適宜 HTML に埋め込んでいます。
ポイントは <meta> タグのみを記載し、実際のコンテンツは window.location でリダイレクトしているところです。こうすると、 facebook / twitter などはリダイレクトされず、通常ユーザは /@note/?noteID=xxx へリダイレクトされます。
このままだと正規の URL /@note/xxx が /@note/?noteID=xxx になってしまうため HTML5 history replace を使って URL を書き換えてあげます。vue-router を使っている例はこちら。 $route.query でクエリパラメータがあった場合は正規 URL に変更してあげるような処理です。
created () {
  if (this.$route.query.noteId) {
    this.$router.replace('/@note/' + this.$route.query.noteId)
  }
}
以上で、 Firebase のみで OGP 対応をすることができました。
