この記事は、 「ten986 Advent Calendar 2022」 の 16 日目の記事です。
背景
「ten986 Advent Calendar 2022」は、個人ブログ を毎日開発しながら、その個人ブログに今年考えたことを毎日投稿するという内容の 気の狂った 企画です。
個人ブログは Next.js + Vercel で開発していて、開発過程は個人ブログに載せています。1日目に初めて動くものが公開され、2日目以降は機能追加を主にしています。
開発過程で、/pages
以下に実装していたページを、Next.js 13 の新機能である /app
以下に移行しました。その際の過程で起きたことが色々あったので、個人ブログではなくちゃんと見やすい場所に投稿します。
個人ブログの状況
ブログの構成
- Next.js + Vercel
- Next.js 13
-
Next.js の examples の、blog-starter を参考に作成した
- この時点では、従来通り
/pages
ディレクトリを使っていた - プロジェクト配下に
/_posts
ディレクトリを作成し、markdown を配置する -
getStaticProps
でビルド時にブログ内容を/_posts
から取得し、json に変換しておく- remark, rehype による変換
- 変換した json をコンポーネントで受け取り、
ReactElement
に変換する- rehype による変換
- 「img は
next/image
に変換してみるか」くらいの気持ち
- この時点では、従来通り
app ディレクトリに移すモチベーション
今までの構成では「変換した json をコンポーネントで受け取り、ReactElement
に変換する」処理をクライアントサイドで行なっていました。パーサーライブラリをダウンロードさせるのはあまり嬉しくありませんし、(可能性は低いですが)間違った json が入ってしまえばクライアントサイドで任意コード実行をしてしまう危険性があると踏みました。そのため、Server Components に処理を閉じることを決意します。
Next.js 12 では nextConfig
に serverComponents: true
を入れつつ、拡張子を .server.tsx
にすることで Server Components を使うことができたのだが、Next.js 13 ではこの serverComponents
の設定は使えなくなっていました(Invalid next.config.js options detected
)。
そのため Next.js 13 を使っている現状、app ディレクトリを使わざるを得ないという状態になっていました。バージョンを下げる気にもならないし、新機能を使ってみたい気持ちはあったので、どうせなので使うことにしました。
やったこと
何はともあれ Upgrade Guide を見る
Next.js 12 から 13 にアップグレードするための、「Next.js 13 Upgrade Guide」
があります。 その中には「Migrating from pages to app」という項目があり、基本的にはこれに従ってアップグレードを試みます。
今回重要になるのが、Data Fetching の migration です。 app ディレクトリでは fetch
関数が用意され、getServerSideProps
や getStaticProps
、getInitialProps
は fetch
に置き換えられました。
このブログでは getStaticProps
でビルド時にブログ内容を /_posts
から取得し、json に変換しておく処理があります。 これを fetch
関数に置き換えたいわけですが・・・そうも行きません。Upgrade Guide の getStaticProps の章 では、fetch
により外部APIか直接DBから取得可能と書かれています。 /_posts
はローカルにあるただのディレクトリでしかないので、fetch
を使う方法ではうまく行きません。
そこで、別のルートで /_posts
以下の内容を json に変換する必要が出てきました。
/_posts
以下の内容を json に変換する手段
状況を解決するには、「ビルド時に /_posts
以下の内容を json に変換する何かしらの手段」が必要です。 作ればいいのですが、どうせなので実例を調べることにしました。
Vercel の templates で Next.js 13 と調べると、3件ヒットし、どうも https://github.com/shadcn/taxonomy は、ローカルでブログポストを書いて、ビルド時に変換する機構を持っているので参考になりそうです。
このプロジェクトでは Contentlayer を使用しています。 Contentlayer は、ローカルファイルの markdown や mdx を、デバッグ実行での監視時やビルド時に、remark と rehype を用いて json へ変換します。 現在はベータ版で、Next.js にのみ対応しています。今後はソースとしてローカル以外にも Contentful や Notion、環境も Remix や SvelteKit などへの対応を計画しているそうです。
今手っ取り早く欲しい機能が実現できる上に、変換する処理のみ Contentlayer に任せることにすることでいざとなれば剥がすことは容易かなと思ったので、今回は Contentlayer を採用しました。
ドキュメントに従うだけでは設定が上手くいかず、少し手間取りましたが、playgroundがあったためエスパーがしやすかったです。
ところで、ブログ記事の管理方法を、ローカルファイルではなく、DBに突っ込むなり、何かしらサーバーから取得できる管理方法にしようかな、とも思い始めました。 どうすっかね。
pages → app の移植
やっと準備ができたので、改めて Upgrade Guide に従います。
/pages
以下にあるファイルは、「getStaticProps
や getStaticPaths
など、どうしても /pages
以下で行わなければならないことだけ行う。 コンポーネント本体は別ディレクトリに切り分ける」 という規約を設けていたので、コンポーネント自体の移植は全く苦労しませんでした。
課題点は前項目で突破しているので、基本は Upgrade Guide と contentlayer のドキュメントと睨めっこしながら移行する作業をします。
app
ディレクトリはデフォルトで Server Component になりますが、'use client'
をファイルの先頭につけることで Client Component になります。 移植時のコツとしては、ひとまず動かないところは Client Component にしてしまって動くことを確認してから、原因を特定することっぽいです。
個人ブログでは react-icons を使っていましたが、react-icons は設定の共有に <Provider/>
を使います。 Server Component では context を使えないので、<Provider/>
と、アイコンの表示部分だけ Client Component として切り出しました。 アイコンの表示部分だけ専用コンポーネントになっていてやや不思議ですね。 もしかしたら専用コンポーネントにするなら <Provider/>
要らないまである?
起こったこと
Client Component で import に失敗
アイコンの表示部分のコンポーネントでは、以下のような import が必要でした。 ちゃんと追求してないけど、どうせクライアントサイドで 'react-icons/ri'
にアクセスできるわけではないので、予めすべてインポートする必要があるんだろうなあって思ってます。 もっといい方法はあると思うけど真面目に調べてない。
// before
import { RiPencilFill } from 'react-icons/ri';
// after
'use client'
import * as ReactIconsRi from 'react-icons/ri';
const { RiPencilFill } = ReactIconsRi
ress が効かない
ress は、リセットCSSの1つです。 _app.tsx
で import 'ress'
することで使えるのですが、Server Component に移行した影響で効かなくなっていました。
ひとまず、import 'ress/dist/ress.min.css'
に変更することで対応しました。 モジュールの解決どうなってんだろう。
時間がバグった
Day.js の timezone を設定していなかったのですが、Server Component に移行したところ、時間表示がバグりました。
たまたま日本時間を基準にして、たまたま日本で開いていたからバグっていなかっただけで、Server Component としてサーバーサイドで処理する際に日本以外の timezone で処理されて、バグが発現しただけのようです。
修正はやるだけ。
Image で React Hydration Error
p タグ内に next/image
コンポーネントがあると、React Hydration Errorが起こります。
何故か今まで起きていませんでしたが、移植の際になんか起こるようになりました。 useEffect
で誤魔化せてただけなんかな。
markdown の変換の都合上、p 内に img が入ることは起こり得るので、ここは p を div に変換する処理を噛ませることで回避しました。
iOS でレスポンシブ対応が消滅
たまたま手元の iPhone で見たところ、モバイル向けの表示に対応していたはずが、PC向けの表示になっていました。 多分 Server Component に移行した影響なんだと思います。 そうなるんだ。
とりあえず実機でしか起こらなかったので、実機デバッグを始めました。 初体験で、記事も初めて知りました。
結果としては、Viewport meta tag をつけることで解決しました。
ページ移動してもタイトルが更新されない
head.ts
に meta タグを入れることで、タイトルが変わるようにしようとしていましたが、ページ遷移してもタイトルが変わりませんでした。
これは v13.0.3
を使っていたことがまず原因で、この issue で現象が報告され、v13.0.4
で解決されていました。
実装当時の最新バージョンは v13.0.6
であったため、更新することにしました。
すると、別のバグ発生しました。
Link
を踏んでページ遷移しようとすると、1回押しただけでは Uncaught TypeError: Cannot read properties of null (reading 'type')
と出て遷移できず、2回押さないと遷移できないバグが発生しました。 これは致命的です。
仕方ないので挙動を確認したところ、どうもある一定の meta タグが処理できず、エラーが発生しているようです。 その処理できない meta タグには Viewport meta tag を含んでいました。 あまり条件は分からず、name
や content
を含んでいることが絶対悪ということでもなさそうです。 謎。
使えないものは仕方ないので、v13.0.3
に戻し、先程の isuue 内の TitleUpdater を使って誤魔化すことにしました。
おわりに
以上で app ディレクトリへの移行が完了し、目的だった「処理をServer Componentに閉じること」が達成されました。 めでたし。
反面、移行時に想定外のバグを踏むことも多く、特に最後のような未解決のバグを踏むこともあるようで、会社のプロダクトではまだ使いたくないな・・・という感想が出ました。