【Next.js 13】開発中の個人ブログを app ディレクトリに移行した話

この記事は、 [「ten986 Advent Calendar 2022」](https://qiita.com/advent-calendar/2022/ten986
) の 16 日目の記事です。

# 背景

「ten986 Advent Calendar 2022」は、[個人ブログ](https://www.ten986.net/posts) を毎日開発しながら、その個人ブログに今年考えたことを毎日投稿するという内容の ~~気の狂った~~ 企画です。

個人ブログは **Next.js + Vercel** で開発していて、開発過程は個人ブログに載せています。1日目に初めて動くものが公開され、2日目以降は機能追加を主にしています。

開発過程で、`/pages` 以下に実装していたページを、Next.js 13 の新機能である `/app` 以下に移行しました。その際の過程で起きたことが色々あったので、個人ブログではなくちゃんと見やすい場所に投稿します。

# 個人ブログの状況

## ブログの構成

- Next.js + Vercel
    - Next.js 13
- [Next.js の examples](https://nextjs.org/examples) の、[blog-starter](https://github.com/vercel/next.js/tree/canary/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」
](https://beta.nextjs.org/docs/upgrade-guide)があります。 その中には[「Migrating from pages to app」](https://beta.nextjs.org/docs/upgrade-guide#migrating-from-pages-to-app)という項目があり、基本的にはこれに従ってアップグレードを試みます。

今回重要になるのが、Data Fetching の migration です。 app ディレクトリでは `fetch` 関数が用意され、`getServerSideProps` や `getStaticProps`、`getInitialProps` は `fetch` に置き換えられました。
このブログでは `getStaticProps` でビルド時にブログ内容を `/_posts` から取得し、json に変換しておく処理があります。 これを `fetch` 関数に置き換えたいわけですが・・・そうも行きません。[Upgrade Guide の getStaticProps の章](https://beta.nextjs.org/docs/upgrade-guide#static-site-generation-getstaticprops) では、`fetch` により外部APIか直接DBから取得可能と書かれています。 `/_posts` はローカルにあるただのディレクトリでしかないので、`fetch` を使う方法ではうまく行きません。

そこで、別のルートで `/_posts` 以下の内容を json に変換する必要が出てきました。

## `/_posts` 以下の内容を json に変換する手段

状況を解決するには、「ビルド時に `/_posts` 以下の内容を json に変換する何かしらの手段」が必要です。 作ればいいのですが、どうせなので実例を調べることにしました。

[Vercel の templates で Next.js 13 と調べる](https://vercel.com/templates?search=next.js+13)と、３件ヒットし、どうも [https://github.com/shadcn/taxonomy](https://github.com/shadcn/taxonomy) は、ローカルでブログポストを書いて、ビルド時に変換する機構を持っているので参考になりそうです。

このプロジェクトでは [Contentlayer](https://www.contentlayer.dev/) を使用しています。 Contentlayer は、ローカルファイルの markdown や mdx を、デバッグ実行での監視時やビルド時に、remark と rehype を用いて json へ変換します。 現在はベータ版で、Next.js にのみ対応しています。今後はソースとしてローカル以外にも Contentful や Notion、環境も Remix や SvelteKit などへの対応を計画しているそうです。

今手っ取り早く欲しい機能が実現できる上に、変換する処理のみ Contentlayer に任せることにすることでいざとなれば剥がすことは容易かなと思ったので、今回は Contentlayer を採用しました。
ドキュメントに従うだけでは設定が上手くいかず、少し手間取りましたが、[playground](https://www.contentlayer.dev/examples/nextjs)があったためエスパーがしやすかったです。

ところで、ブログ記事の管理方法を、ローカルファイルではなく、DBに突っ込むなり、何かしらサーバーから取得できる管理方法にしようかな、とも思い始めました。 どうすっかね。

## pages → app の移植

やっと準備ができたので、改めて Upgrade Guide に従います。

`/pages` 以下にあるファイルは、「`getStaticProps` や `getStaticPaths` など、どうしても `/pages` 以下で行わなければならないことだけ行う。 コンポーネント本体は別ディレクトリに切り分ける」 という規約を設けていたので、コンポーネント自体の移植は全く苦労しませんでした。

課題点は前項目で突破しているので、基本は Upgrade Guide と contentlayer のドキュメントと睨めっこしながら移行する作業をします。
`app` ディレクトリはデフォルトで Server Component になりますが、`'use client'` をファイルの先頭につけることで　Client Component になります。 移植時のコツとしては、ひとまず動かないところは Client Component にしてしまって動くことを確認してから、原因を特定することっぽいです。

個人ブログでは　[react-icons](https://react-icons.github.io/react-icons/) を使っていましたが、react-icons　　は設定の共有に `<Provider/>` を使います。 Server Component では　context を使えないので、`<Provider/>` と、アイコンの表示部分だけ Client Component として切り出しました。 アイコンの表示部分だけ専用コンポーネントになっていてやや不思議ですね。 もしかしたら専用コンポーネントにするなら　`<Provider/>`　要らないまである？

# 起こったこと

## Client Component で import に失敗

アイコンの表示部分のコンポーネントでは、以下のような import が必要でした。 ちゃんと追求してないけど、どうせクライアントサイドで `'react-icons/ri'` にアクセスできるわけではないので、予めすべてインポートする必要があるんだろうなあって思ってます。 もっといい方法はあると思うけど真面目に調べてない。

```ts
// before
import { RiPencilFill } from 'react-icons/ri';

// after
'use client'

import * as ReactIconsRi from 'react-icons/ri';
const { RiPencilFill } = ReactIconsRi
```

## ress が効かない

[ress](https://github.com/filipelinhares/ress) は、リセットCSSの1つです。 `_app.tsx` で `import 'ress'` することで使えるのですが、Server Component に移行した影響で効かなくなっていました。
ひとまず、`import 'ress/dist/ress.min.css'` に変更することで対応しました。 モジュールの解決どうなってんだろう。

## 時間がバグった

[Day.js](https://day.js.org/) の timezone を設定していなかったのですが、Server Component に移行したところ、時間表示がバグりました。
たまたま日本時間を基準にして、たまたま日本で開いていたからバグっていなかっただけで、Server Component としてサーバーサイドで処理する際に日本以外の timezone で処理されて、バグが発現しただけのようです。
修正はやるだけ。

## Image で React Hydration Error

p タグ内に `next/image` コンポーネントがあると、[React Hydration Error](https://nextjs.org/docs/messages/react-hydration-error)が起こります。
何故か今まで起きていませんでしたが、移植の際になんか起こるようになりました。 `useEffect` で誤魔化せてただけなんかな。
markdown の変換の都合上、p 内に img が入ることは起こり得るので、ここは p を div に変換する処理を噛ませることで回避しました。

## iOS でレスポンシブ対応が消滅

たまたま手元の iPhone で見たところ、モバイル向けの表示に対応していたはずが、PC向けの表示になっていました。 多分 Server Component に移行した影響なんだと思います。 そうなるんだ。

とりあえず実機でしか起こらなかったので、実機デバッグを始めました。 初体験で、記事も初めて知りました。

https://qiita.com/unsoluble_sugar/items/2a3d06631a6b8259dc44

結果としては、[Viewport meta tag](https://developer.mozilla.org/ja/docs/Web/HTML/Viewport_meta_tag) をつけることで解決しました。

## ページ移動してもタイトルが更新されない

`head.ts` に meta タグを入れることで、タイトルが変わるようにしようとしていましたが、ページ遷移してもタイトルが変わりませんでした。

これは `v13.0.3` を使っていたことがまず原因で、[この issue](https://github.com/vercel/next.js/issues/42414) で現象が報告され、`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](https://github.com/vercel/next.js/issues/42414#issuecomment-1304949304) を使って誤魔化すことにしました。

# おわりに

以上で app ディレクトリへの移行が完了し、目的だった「処理をServer Componentに閉じること」が達成されました。 めでたし。
反面、移行時に想定外のバグを踏むことも多く、特に最後のような未解決のバグを踏むこともあるようで、会社のプロダクトではまだ使いたくないな・・・という感想が出ました。