NetlifyにデプロイされたGatsby製のサイトでトレイリングスラッシュを削除する

サーチコンソールと仲良くなるための4つの方法

Soudai Sasada

Soudai Sasada

このブログはGatsbyJSでContentfulに保存されたコンテンツを元にサイトを生成しNetlify上にデプロイしています。いわゆる「ヘッドレスCMS x SSG」というやつですね。

ようやくある程度セットアップが終わりサーチコンソールを導入してみたところ、Contentfulのコンテンツを元に生成されるいくつかの記事でトレイリングスラッシュ付きのパスが正規URLとして登録されていることに気が付きました。
意図してそうしている分には問題ありませんが筆者はむしろトレイリングスラッシュがない状態を想定してブログを構築していたためこれは問題です。そこで色々と調べてみることにしました。

結論

先に結論を書いてしまうと完全には問題を解決することはできませんでした。
これはどういうことかというとGoogleはcanonical URLが設定されていたとしても必ず指定したURLを正規とみなすわけではないためのようです。

なお、正規ページを明示した場合でも、さまざまな理由(パフォーマンスやコンテンツなど)から Google のアルゴリズムで別のページが正規として選択されることもあります。

https://developers.google.com/search/docs/advanced/crawling/consolidate-duplicate-urls?hl=ja

とはいえまだ修正を反映したばかりなのでこれから結果が出てくるかもしれません。今回はGoogleのガイドラインを元に以下の対策を行いました。

  • rel="canonical" リンクタグを使用する
  • rel="canonical" HTTP ヘッダーを使用する
  • サイトマップを使用する

以下の対策は残念ながらNetlifyではできなかったため断念しました。

  • 廃止する URL に 301 リダイレクトを使用する

ただNetlifyの設定を見直したところ「トレイリングスラッシュをあえて付ける」機能がありこれがデフォルトでオンとなっていることが分かったためこれを停止しました。これが原因でトレイリングスラッシュ付きのURLが正規URLとしてインデックスされる原因となっていたので停止したことでなにか変化があるかもしれません。これについては後ほど触れます。

それでは順番に具体的な対応手順を紹介します。

rel="canonical" リンクタグを使用する

HTMLのlink要素でcanonical属性に正規URLを渡すことでそのページのトレイリングスラッシュが付いていないURLを設定するだけです。

筆者はreact-helmetを使っているのでlink要素を追加できるようにしました。

src/components/meta/index.js
import React from "react"
import PropTypes from "prop-types"
import { Helmet } from "react-helmet"
import { useStaticQuery, graphql } from "gatsby"

function Meta({ description, lang, meta, title, link }) {
  const { site } = useStaticQuery(
    graphql`
      query {
        site {
          siteMetadata {
            title
            description
            author
          }
        }
      }
    `
  )

  const metaDescription = description || site.siteMetadata.description

  return (
    <Helmet
      htmlAttributes={{
        lang,
      }}
      title={title}
      titleTemplate={`%s | ${site.siteMetadata.title}`}
      defaultTitle={site.siteMetadata.title}
      meta={[
        {
          name: `description`,
          content: metaDescription,
        },
        {
          property: `og:title`,
          content: title,
        },
        {
          property: `og:description`,
          content: metaDescription,
        },
        {
          property: `og:type`,
          content: `website`,
        },
        {
          name: `twitter:card`,
          content: `summary_large_image`,
        },
        {
          name: `twitter:creator`,
          content: `@soudai_s`,
        },
        {
          name: `twitter:title`,
          content: title,
        },
        {
          name: `twitter:description`,
          content: metaDescription,
        },
      ].concat(meta)}
      link={[].concat(link)}
    />
  )
}

Meta.defaultProps = {
  lang: `ja`,
  meta: [],
  description: ``,
  link: [],
}

Meta.propTypes = {
  lang: PropTypes.string,
  meta: PropTypes.arrayOf(PropTypes.object),
  description: PropTypes.string,
  link: PropTypes.arrayOf(PropTypes.object),
  title: PropTypes.string,
}

export default Meta

呼び出す側ではオブジェクトの配列としてlink要素を渡せます。

src/templates/blogpost/index.js
import React from "react"

import Meta from "../../components/meta"

const BlogPost = ({ data }) => {
  const { title, slug, description, thumbnail, body, category, publishedAt } = data.contentfulBlogPost
  const url = `${data.site.siteMetadata.siteUrl}/${slug}`
  return (
    // Do something...
      <Meta
        link={[
          { rel: "canonical", href: url }
        ]}
      />
  )
}
export default BlogPost

実際に設置したらブラウザの開発ツールを使って確認してみましょう。ソースコードの変更なのでローカルで確認できますね。

ここでの注意点は2つ。インデックスして欲しいURLをhref属性に指定すること、そして絶対パスを指定することです。相対パスの場合は正しく解釈してくれません。

rel="canonical" HTTP ヘッダーを使用する

HTTPにはLinkというレスポンスヘッダがあります。これはたとえばPDFファイルのようにhtmlではない(マークアップで正規URLを伝えることが不可能な)ドキュメントでGoogle Botに正規URLを伝えるための手段として使うことができます。

Netlifyではルートディレクトリに予め決められた命名規則のファイルを設置することでHTTPレスポンスヘッダを含む様々な設定を変更することが可能で、HTTPレスポンスヘッダはプロジェクトルートに_headersというファイルを用意するか、Netlify全体に関する様々な設定を記述可能なnetlify.tomlというファイルを設置することでカスタマイズが可能です。

今回はtomlファイルでの設定しました。forディレクティブは相対パス(こちらはNetlifyが解釈するためのパスなので絶対パスでも問題ないかもしれませんが試していません)、実際にLinkヘッダに設定する値は絶対パスとなる点に注意してください。

netlify.toml
[[headers]]
  for = "/hogehoge.pdf"
  [headers.values]
    link = "<https://soudai-s.com/hoge.pdf>; rel=\"canonical\""

[[headers]]
  for = "/fugafuga.pdf"
  [headers.values]
    link = "<https://soudai-s.com/fuga.pdf>; rel=\"canonical\""

設定をしたらデプロイし実際に確認してみましょう。

# 実際にご自身が確認したいURLを指定しましょう
$ curl -I https://example.com/hogehoge.pdf
HTTP/2 200 
cache-control: public, max-age=0, must-revalidate
content-length: 0
content-type: text/html; charset=UTF-8
date: Tue, 23 Mar 2021 05:41:09 GMT
etag: "XXXXXXXXXXXXXXXXXXXXXXXXX-XXX"
link: <https://example.com/hoge.pdf>; rel="canonical"
strict-transport-security: max-age=31536000
age: 0
server: Netlify
x-nf-request-id: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx-xxxxxxxx

意図した通りに設定ができていなければドキュメントを見ながら調整しましょう。

サイトマップを使用する

サイトマップを作成し送信することでクローラーにクローリングして欲しい内容を伝えることが可能です。Gatsbyではgatsby-plugin-sitemapという便利なプラグインがあります。

筆者はGatsbyからContentful上のコンテンツをgatsby-nodeにより生成していたためそれらを送信できるようにオプションを変更しました。オプションの中でGraphQLを通じContentfulのコンテンツを取得、serializeコンテキストでその情報を元に記事のURLを生成しsitemapに反映させています。

gatsby-config.js
    {
      resolve: `gatsby-plugin-sitemap`,
      options: {
        query: `
          {
            site {
              siteMetadata {
                siteUrl
              }
            }
            allContentfulBlogPost(
              filter: { node_locale: { eq: "ja-JP" }, published: { eq: true } }
            ) {
              edges {
                node {
                  slug
                }
              }
            }
            allSitePage {
              nodes {
                path
              }
            }
          }
        `,
        resolveSiteUrl: ({ site, allContentfulBlogPost }) => {
          return site.siteMetadata.siteUrl
        },
        serialize: ({ site, allContentfulBlogPost }) => {
          const blogPosts = allContentfulBlogPost.edges.map(edge => {
            return {
              url: `${site.siteMetadata.siteUrl}/${edge.node.slug}`,
              changefreq: `daily`,
              priority: 0.7,
            }
          })
          return [{ url: site.siteMetadata.siteUrl, changefreq: `daily`, priority: 0.5 }, ...blogPosts]
        },
      }
    },

このプラグインはGatsbyのプラグインの中でもドキュメントがかなり整っていますので詳しい設定方法について知りたければドキュメントを見るのが間違いなさそうです。

こちらも設定が終わったらプロダクションモードで起動し/sitemap.xmlにブラウザからアクセスしてみましょう。

$ gatsby build && gatsby serve
$ open http://localhost:9000/sitemap.xml

/sitemap.xmlにアクセスして確認ができれば問題ないです。 また、サーチコンソールを見ることで正常に送信できているか確認できます。リリース後はサーチコンソールも確認しましょう。

廃止する URL に 301 リダイレクトを使用する

こちらはNetlifyでトレイリングスラッシュを削除する目的での利用は難しいようでした。
ドキュメントを見る限りNetlifyのCDNではパフォーマンスの最適化をするためにトレイリングスラッシュを考慮しないでキャッシュをしています。またこれらの実装に合わせるためNetlifyのリダイレクトルールもトレイリングスラッシュの有無だけでは違いとみなされないため、トレイリングスラッシュがあるものからないもの(またはその逆)に設定しても同一のコンテンツ同士のリダイレクトとみなされ意図したとおりにリダイレクトされることはありません。

Our CDN edge nodes do URL normalization before the redirect rules kick in. This happens to make sure we can guarantee the highest possible cache hit rate and the absolute best performance for your site.

What this means for your redirect rules is that Netlify will match paths to rules regardless of whether or not they contain a trailing slash.

# These rules are effectively the same:
# either rule alone would trigger on both paths
/blog/title-with-a-typo    /blog/typo-free-title
/blog/title-with-a-typo/   /blog/typo-free-title

# This rule will cause an infinite redirect
# because the paths are effectively the same
/blog/remove-my-slashes/   /blog/remove-my-slashes  301!

https://docs.netlify.com/routing/redirects/redirect-options/#trailing-slash

Gatsbyにはgatsby-plugin-remove-trailing-slashesというまさにトレイリングスラッシュを削除するためのプラグインがありますが、これはgatsby-node.jsonCreatePageという関数を使いトレイリングスラッシュ付きのパスを強制的にトレイリングスラッシュなしのパスに変換しているだけです。

gatsby-node.js
// Replacing '/' would result in empty string which is invalid
const replacePath = _path => (_path === `/` ? _path : _path.replace(/\/$/, ``))

exports.onCreatePage = ({ page, actions }) => {
  const { createPage, deletePage } = actions

  return new Promise(resolve => {
    const oldPage = Object.assign({}, page)
    page.path = replacePath(page.path)
    if (page.path !== oldPage.path) {
      deletePage(oldPage)
      createPage(page)
    }
    resolve()
  })
}

onCreatePageは無限ループを防ぐためgatsby-node.jsで生成されたページ以外を対象とするのでcreatePagesで生成されたURLは対象外となるようです。とはいえこれはそもそもcreatePagesでトレイリングスラッシュを付けずにページを生成すれば同じことですね。

実はこのプラグインのドキュメントにも記載のとおり、NetlifyではPrettyURLという設定がトレイリングスラッシュに纏わる問題を引き起こしています。Netlifyのドキュメントの続きを読むと/about/about.htmlといったパスは/about/に書き換えると書いてあるではありませんか。
ということで早速実際に試してみたところこれまで起きていた「リロードするとトレイリングスラッシュが付いてしまう」という事象が解消されていました。

もしNetlify上で展開しているサイトで意図せずトレイリングスラッシュ付きのパスがインデックスされているならまずここを見直しましょう。他の対策はそれから始めると良いと思います。

netlify-asset-optimization

ヘッダーメニューのSite Settingsから左カラムのBuild & Deployを選択しスクロールしていくとAsset Optimizationという項目があるのでEdit Settingsボタンを押しPretty URLsという項目のチェックを外してSaveボタンを押すことでNetlifyによるトレイリングスラッシュの強制を外すことができます。

なおNetlifyではできませんでしたがトレイリングスラッシュを外す対処であれば本来は301リダイレクトを設定し強制的に転送してしまうのが最も効果的ではないかと思われます(301の意味はMoved Permanently、これはまさしく指定のURLは永続的にLocationヘッダに指定された転送先に移動したことを伝えるためにあるステータスコードです)。Nginxのようなリバースプロキシを使うなりALBで設定をすればわけないのですがそこまでして運用コストを払いたくないがためのNetlifyだと思うのでこの辺りはトレードオフですね。

と、ここまで書いていて気が付きましたがどうやらNetlifyのPretty URLsという設定が301リダイレクトでトレイリングスラッシュを強制的に付ける設定だったようですね。やはりドキュメントは偉大ですね。

まとめ

今回はNetlifyにデプロイされたGatsby製のサイトでトレイリングスラッシュを削除するための方法を紹介しました。色々と調べ物をしたり実際にサーチコンソール上で結果を見ながら試行錯誤したため時間が掛かりましたがやることさえ分かっていればご覧いただいたとおり大して手間は掛かりません。

この記事が同じ構成でこれから新しくサイトを構築しようという方の参考になれば幸いです。

この記事がお役に立てたら一杯のコーヒーを恵んで頂けると励みになります 😊

Buy Me A Coffee
© 2020, Soudai Sasada