makenowjust-labs/blog

MakeNowJust Laboratory Tech Blog

ブログを App Router へ移行しました

2023-12-11 (更新: 2023-12-12)

このブログは現在 (2023年12月11日付) Next.jsで実装されています (移行の際の記事)。

これまでは実装にPages Routerを使っていました。 最近「カテゴリ機能」(現在は「タグ機能」) を追加したのですが、その際にカテゴリの一覧の取得をすべてのページのgetStaticPropsで行わなければならず、不便に感じていました。 そうした問題の解消のために、App Routerへと移行しました。

他にもOGP画像の生成にsatoriを使うようにしたり、GitHub PagesへのデプロイをGitHub Actionsで成果物をアップロードする形にしたり、色々と修正をしました。 それらの実装を通じて学んだことを、この記事では整理します。

これまでの実装と問題点

これまでこのブログは次のような実装になっていました。

  • Next.jsのPages Routerを使ってSSG
  • SSG時にMarkdownファイルを読み込んで描画。
  • OGP画像をskia-canvasを使って生成。

このような実装になった経緯は「Next.jsに移行した際の記事」と「OGP画像の生成を実装した際の記事」を参照してください。

さて、最近このブログに「カテゴリ機能」を導入しました。 これはブログの記事にカテゴリを設定できて、それらの一覧を見れるようにする機能です。

「カテゴリ一覧」はページの下部に表示されていて、そのカテゴリの記事の一覧のページへと飛べるようになっています。

「カテゴリ一覧」のスクリーンショット

「カテゴリ一覧」のスクリーンショット (現在は「タグ一覧」になっています)

この機能は各ページのgetStaticPropsでカテゴリ一覧を取得して、それをFooterコンポーネントへと渡すことで実現されていました (参考)。

一応、この方法でも正しく実装できるのですが、すべてのページのgetStaticPropsでカテゴリ一覧を取得する必要がありました。 そのため、新しいページを追加した際にカテゴリ一覧の取得を忘れないようにする必要があったり、ページに共通して表示する要素を増やす場合に (例えば最新記事一覧)、同じように全てのページにそのデータ取得するコードを追加する必要があり、かなり煩雑でバグの原因にもなりやすいように思えます。

今回はNext.jsのApp Routerへと移行し、Server Componentを上手く利用することで、こういった問題を解決しました。

App Routerとは

App RouterはNext.jsのバージョン13から追加された、新しいファイルベースのルーティングの仕組みです。

以前のPage Routerとの主な違いは次の通りです。

  • ファイルをappディレクトリに配置する。
  • 1つのディレクトリが1つのパスに対応して、その中にpage.tsxlayout.tsxを配置する。
  • コンポーネントはデフォルトでServer Componentになる。

この中で重要なのは、コンポーネントがServer Componentになるという点です。

Server Componentは (一応) Reactの機能の1つで、サーバーで描画する際に非同期でデータを取得するようなコンポーネントが利用できるようになるものです。 つまり、async関数をReactのコンポーネントとして記述できるようなり、例えば次のような関数をコンポーネントとして使えるようになります。

import fs from "fs/promises";
 
const ReadMe = async () => {
  const contents = await fs.readFile("README.md", "utf-8");
  return (
    <div>
      <h1>README.mdの中身</h1>
      <pre>
        <code>{contents}</code>
      </pre>
    </div>
  );
};

このコンポーネント場合、サーバーでの描画中にREADME.mdの内容が取得されて、描画されることになります。

Server Componentの子要素として、さらにServer Componentをネストさせることもできます。 その場合も同様に、子要素で非同期なデータの取得が可能になります。 今回はこれがとても重要で、今まではページに対応するファイルのgetStaticPropsで一度に取得するしかなかったデータを、レイアウトファイルの中でも取得できるようになるわけです。

というわけでApp Routerへの移行を決定しました。

移行の流れ

ここからは、実際の移行の流れを説明していきます。

方針

commlogのときと同様に、今回もアプリを1から作り直すことで移行しました。 というのも、MarkdownからMDXへの移行などの他の課題も一度に解決してしまおうと思ったためです。 修正点が多くなるのは分かっていたので、完全に作り直した方が結果的に楽だろうという判断でした。 意外と元のコードと変わらなかった部分も多いので、今回の場合はどちらでも良かった気がします。

さて、最初はcreate-next-appを実行しました。

bunx create-next-app blog

色々聞かれたけれど、基本的にはデフォルトのままだったと思います。

また、bunxを利用していることから分かるように、今回はBunを利用しています。 Bunを使ったことによる問題は今のところ1つだけでした。 それなりにやれているのではないかと思います。

MDXへの移行

最初に行ったのはMarkdownからMDXへの移行です。

MDXはMarkdown中にJavaScriptやJSXを記述できるようにしたマークアップ言語です。 JSXが記述できるので、Next.jsの<Image>コンポーネントを使えるようになるなど、様々な利益があります。 今回もいくつかの箇所でMDXの機能を利用しています。

Next.jsでは@next/mdxを追加することで、.mdxファイルがimportできるようになったり、appディレクトリに置いたmdxファイルがルーティングのパスとして認識されるようになったりします。

今回悩んだのは、MDXファイルをどのようにNext.jsのルーティングに反映させるかでした。

実装を始めた当初は、app/postディレクトリ以下に各記事のMDXファイルを配置することで、Next.jsのルーティングに反映させる方法を考えていました。 しかし、この方法ではレイアウトから現在描画しようとしているMDXファイルのfrontmatterを取得できず、ページのタイトルなどを上手く設定できなそうだったため、別の方法を取ることにしました。

そこで取ったのが、記事はpostsディレクトリに配置して、app/post/[slug]/page.tsxから[slug]パラメータに応じて動的importする、という方法になります。 こうすることでMDXファイルのfrontmatterを見てページのタイトルなどを設定できるようにしました。 また、関連する画像ファイルなども近くに置けるようになったので、悪くない選択だったと思っています。

ただ、必要な情報をmetadataとしてexportするrehypeのプラグインを作ればappディレクトリに置く方針でも上手くいったような気もします。

excerptの取得

MDXファイルのexcerpt (トップページなどで表示される記事の冒頭の抜粋) をどうやって取得するかは課題の1つでした。

これまではgray-matterの機能を利用していました。 しかし、MDXではこれを直接利用することはできません。

最初は、rehype-infer-description-metaが利用できないかと模索していました。 rehype-infer-description-metarehypeのプラグインで、処理中の文書の抜粋を取得して、file.data.meta.descriptionfile.data.meta.descriptionHastにその値を設定します。 この設定した値をどうにかしてMDXからexportすればいいと思い、remark-mdx-frontmatterを参考にそのようなrehypeの自前のプラグインを実装しました。

しかし、rehype-infer-description-metaの中で行っているのはhast-util-excerptというライブラリを呼び出しているだけだったので、最終的にはexcerptを取得する処理もそれを使って自前で実装し、プラグインとして作りました。

そうして実装したのがrehype-mdx-excerpt.mjsになります。

また、以前の実装でもMarkdown-Itのプラグインとして実装していたpseudocodeの対応も、同じくプラグインとして実装しています。

Imageの問題

せっかくMDXを使えるようになったのだからnext/imageを使おうとしたところ、次のようなエラーが出てしまいました。

Unhandled Runtime Error
Error: Cannot access Image.propTypes on the server. You cannot dot into a client module from a server component. You can only pass the imported name through.

恐らくNext.jsのバグのようでIssueもあるので、ひとまず様子を見つつ、今回は一旦next/legacy/imageを代わりに使うことで解決しました。 本当にこれでいいのかよく分かりませんが、そもそもSSGの場合画像の最適化なども適用されないため、<Image>の役目は画像のサイズを確保しておくくらいなので、そこまで問題にならないでしょう。

satoriを使ったOGP画像の生成

OGP画像の生成をBunで実行してみたところ、skia-canvasの読み込みでエラーになってしまいました。 skia-canvasはBunに対応していないようなので、別の方法を取る必要がありそうでした。

satoriはJSXで書かれたHTMLのサブセットからSVGを生成するライブラリです。 テキストのフォントをパスに変換したりしてくれるのでその後にSVGを描画することでスムーズに画像ファイルが得られます。 また、CSSのflexプロパティによるレイアウトをサポートしているため、そこそこ複雑なレイアウトができます。

今回はこのsatoriで出力したSVGをresvg-jsで描画することで、画像ファイルを生成することにしました。

工夫をしたのは、タイトルの改行の制御です。 以前の実装と同様にbudouxを用いたのですが、satoriは<wbr>要素などはサポートしていません。 そこで、分かち書きされた部分を<span>に入れて、flex-wrapwrapに生成することで、flexコンテナの幅を越えた子要素が次の行になるようにすることで、いい感じに改行されるようにしました。 この方法はこちらの記事を参考にしました。

GitHub Pagesへのデプロイ

最後に、GitHub Pagesへの少し変更しました。

これまではgh-pagesブランチにpushしていたのですが、GitHub Actionsを使って成果物をアップロードしてデプロイする方式に変更しました。

これには次の2つのGitHub Actionsを利用します。

ブランチが減った分cloneなどが多少早くなるのかもしれません。 他のプロジェクトもこっちの方法にしたいな (とくにcommit数が膨大なdiary)。

まとめ

というわけで、今回はブログのApp Routerへの移行に合わせて、ブログを全面的に書き直しました。

最終的に次のようなことを行いました。

  • Bunへの切替。
  • App Routerを利用したSSGへの移行。
  • MarkdownからMDXへの切替。
  • satoriを使ってOGP画像を描画。
  • GitHub Actionsを使ったGitHub Pagesへのデプロイの実装。

全体的に今後も管理しやすい実装になったのではないかと思います。

それでは、最後まで目を通していただきありがとうございました。