WordPressをNuxt 3でヘッドレスCMS化してみた

ヘッドレスCMSとは?

 ヘッドレスCMSとは簡単に言えば、「画面の無いCMS」になります。コンテンツ入力部分をWordPressやDruapal等の既存のCMSを利用し、画面部分はCMSを使わず違うソリューションで行うシステムとなります。

ヘッドレスCMSのメリット

 個人的には以下の3点がCMSと比較した優位点であると思います。

  • UI開発自由度が非常に上がる。
    • WordPressのUI機能に依存する必要がないので、画面開発の自由度が増します。WordPressを使いつつ、SPAぽい作りにしたり(実際はAPI呼び出しでコンテンツを取得しているので純粋なSPAではありません)、UIフレームワークをBootstrapからQuaserにしたりと画面はほぼ自由に開発することが可能です。
  • セキュリティが向上する
    • WordPressのセキュリティパッチを気にする必要はないので、セキュリティレベルは上がります。ただWordPress APIの内容をそのまま出力する処理が増えるのでサニタイズは必須です。
  • パフォーマンスが向上する
    • 今回実際にやってみた結果パフォーマンスは確かに向上しました。これはヘッドレスCMSにしたからというよりは、ヘッドレスCMSにしたことによりフロントエンドのパフォーマンスチューニングできるポイントが増えたからということが要因だと思います。

 他にもサイト上には他にもメリットは色々と書かれていますが、私がメリットと思うのは上記3点で、WordPressのUIのクオリティに満足できていないが、ブログ等の更新頻度が高いコンテンツはエンジニアでは無い人にも自由に作れるようにしたいという人にはマッチするソリューションだと思います。

システム構成

今回構築したシステム構成は以下になります。

バックエンド(ヘッドレスCMSサーバ)

 WordPressを通常通りインストールします。(本投稿ではWordPressのインストール方法は省略します。)今回WebServerはNginxを使用しています。本サーバは一部の画像コンテンツを除き直接ユーザには公開しませんが、コンテンツ作成者は管理画面を利用可能となる想定です。パブリックには公開せず、公開権限を絞った設定で公開してください。

フロントエンド(公開サーバ)

 フロントエンドのアプリケーション開発には今回Nuxt3を採用しました。またWebサーバをフロントに置き、定番通りに80,443のアクセスをNuxt 3のポートにポートフォワードする構成です。

今回の開発範囲

今回はヘッドレスCMSを使って、以下の3ページを作成していきます。

  1. TOPページ 最新の投稿、最新の技術情報を3件程度WordPressから取得し、サムネイルと数行のコンテンツを引用して表示する。
  2. 投稿一覧ページ 投稿一覧をサムネイルと共に表示
  3. 投稿ページ WordPressと同様の投稿ページを作成。

TOPページの作成

 WordPressサーバからコンテンツを取得するにはWordPressのRest APIを利用します。今回はVue3のsetup関数の中で以下の様な処理で記事情報を取得しています。

     const { data: responses, pending } = await useFetch(
    "https://" +
      "ワードプレスサーバのホスト名" +
      "/wp-json/wp/v2/posts?_embed&orderby=date&per_page=3"
  );

 この様なAPIリクエストをWordPressに投げることにより、各種情報をjson形式で取得することができます。上記の例の場合、最新の投稿から3ページを取得することになります。取得した情報をHTMLとして出力する処理は以下となります。

     <div class="card-group">
        <div v-if="pending">Loading ...</div>
          <div
            class="card d-block"
            v-for="response in responses"
            :key="response.id"
            v-else
          >
          <a :href="response.link.replace("ワードプレスサーバのホスト名" + '/wordpress', "自分のホスト名")">
            <img
              class="card-img-top"
              :src="response._embedded['wp:featuredmedia'][0].source_url"
              v-if="response._embedded && user._embedded['wp:featuredmedia']"
            />
            <div class="card-body">
              <h4
                class="card-title"
                style="
                  font-size: 18px;
                  font-weight: 500;
                  margin-top: 20px;
                  line-height: 22pt;
                "
              >
                {{ response.title.rendered }}
              </h4>
              <caption>
                {{
                  dateFormat(response.date)
                }}
              </caption>
            </div>
          </a>
        </div>
      </div>

 ワードプレスサーバ中の記事情報はワードプレスサーバのドメインでリンク情報が格納されていますので、フロントエンドのドメイン名で置換してあげます。サムネイルに使う画像は_embedded[‘wp:featuredmedia’][0].source_urlというかなり深い階層から取得する必要があります。

  TOPページはこのような形で、RestAPIで取得して必要なものを出力してあげることにより作成可能です。

投稿一覧ページ

 投稿一覧ページもデータ取得はほぼ同様で、ワードプレスのRestAPIで記事情報一覧とカテゴリ情報を取得します。

  const { data: response } = await useLazyFetch<any>(
    "https://" +
      "ワードプレスサーバのホスト名" +
      "/wp-json/wp/v2/posts?_embed&orderby=date&per_page=100"
  );
  const { data: categories } = await useLazyFetch<any>(
    "https://" + "ワードプレスサーバのホスト名" + "/wp-json/wp/v2/categories"
  );

 HTML出力もほぼ同様で、取得した記事情報をv-forで取り出しながら1件づつ出力します。ここでは、サムネイル、公開日、更新日、カテゴリ、タイトル、本文引用部を出力しています。カテゴリに関しては付与された情報を全て出力する為に、v-forで全て出力しています。

                <div v-for="user of getItems" :key="user.id">
            <article style="margin-bottom: 20px" v-if="user">
              <div class="row mymedia" style="flex-wrap: nowrap">
                <div class="col-md-1 thumb" style="margin: 20px 10px">
                  <img
                    :src="user._embedded['wp:featuredmedia'][0].source_url"
                    v-if="user._embedded && user._embedded['wp:featuredmedia']"
                    style="max-width: 200px"
                  />
                </div>
                <div class="col-sm-9">
                  <div class="row" style="margin: 20px 10px">
                    <div class="col">
                      <div style="text-align: left" class="caption">
                        公開日:{{ dateFormat(user.date) }}
                      </div>
                      <div style="text-align: left" class="caption">
                        更新日:{{ dateFormat(user.modified) }}
                      </div>
                    </div>
                    <div class="col">
                      <div
                        v-if="user._embedded"
                        style="text-align: right"
                        v-for="term in user._embedded['wp:term'][0]"
                      >
                        <button
                          type="button"
                          class="btn caption"
                          @click="reloadpage(term.id)"
                          style="margin-bottom: 3px"
                        >
                          {{ term.name }}
                        </button>
                      </div>
                    </div>
                  </div>
                  <div class="row">
                    <a
                      :href="
                        user.link.replace(
                          host?.wphost + '/wordpress',
                          host?.myhost
                        )
                      "
                    >
                      <h4
                        v-html="clean(user.title.rendered)"
                        style="margin-bottom: 10px"
                      ></h4>
                      <div
                        class="col"
                        v-html="clean(user.excerpt.rendered)"
                      ></div>
                    </a>
                  </div>
                </div>
              </div>
            </article>
          </div>

投稿ページ

 投稿ページに関しては、動的にページを作成する必要があるのでNuxtJSの動的ページ作成機能を利用します。pagesディレクトリの下に_slug.vueというファイルを作成して、以下の処理を追加します。

  const route = useRoute();

  const { data: posts } = await useFetch<any>(
    "https://" +
      "ワードプレスのホスト名" +
      "/wp-json/wp/v2/posts?slug=" +
      route.params._slug
  );
  const { data: pages } = await useFetch<any>(
    "https://" +
      "ワードプレスのホスト名" +
      "/wp-json/wp/v2/pages?slug=" +
      route.params._slug
  );

 useRoute.params._slugからURL文字列中に指定された記事名を取り出し、RestAPIのパラメータとして指定してあげます。この処理に特定の記事を取り出すことが可能です。上記処理では、記事と固定ページからコンテンツが存在するかどうか検索を行っています。本処理でコンテンツが取り出せなかった場合、not foundとしてエラーページにリダイレクトを行います。

 出力部分は他のページとほぼ同様ですが、本処理では{{}}は利用せずv-htmlを使ってコンテンツをそのまま出力しています。

            <div v-for="page in pages" :key="page.id">
        <h2 style="margin-bottom: 60px">
          <div v-html="clean(page.title.rendered)"></div>
        </h2>
        <div v-html="clean(page.content.rendered)"></div>
      </div>
      <div v-for="post in posts" :key="posts.id">
        <h2 style="margin-bottom: 60px; text-align: center">
          <div v-html="clean(post.title.rendered)"></div>
        </h2>
        <div v-html="clean(post.content.rendered)"></div>
      </div>

 今回のコンテンツにはユーザから入力するデータは含まれていないので、必要はないのですが、v-htmlはXSSを発生させる危険性があります。(v-htmlの危険性に関してはこちらの記事を参考にしてください。https://qiita.com/tnemotox/items/b4b8f0f627e23dd62447)v-htmlを利用する場合はサニタイズが必要ですので、今回cleanという関数を作成しサニタイズをかけています。

その他調整事項

 これでコンテンツはほぼ完成なのですが、CSSをワードプレス側と同じものを使用する必要がるので、今回devtoolでワードプレス側を利用する場合に参照しているCSSをNuxtJS側のassetsにコピーしてしまいました。この辺はもう少し賢いやり方があるかもしれません。

 また画像に関しては今回はワードプレスから直接参照しています。これもフロントエンドとバックエンドを同期させてあげれば、フロントエンドサーバから参照させることが可能です。

CMSに変更した場合のパフォーマンス向上

最後にWordPressのパフォーマンスとNuxt化した場合のパフォーマンスLighthouseを使って比較してみました。

WordPressで計測

Nuxtで計測

結果めでたくPCサイト測定で78から95の向上に成功しました!この程度あればSEO的には十分です!