Next.js v12 から v13 with app dir に移行する

 / #nextjs #next-mdx-remote #algolia

複数ある Next.js 製サイトのなかで、このブログが最もシンプルな構造でページ数が少ない、ホスティング先が Vercel、未対応の StaticExport を使っていないなどの理由から試しに移行することにした。基本的にはUpgrade Guide | Next.jsに従いつつ、各ライブラリ関連でエラーが出てきたときはそれぞれの GitHub レポジトリの issue 等で確認しながらアップグレードしていきたいと思う。

Next.js v12 から v13 への変更点を読んだ感じだと、検索機能のAlgolia やマークダウンを処理するhashicorp/next-mdx-remote などクライアントサイドで動く辺りでエラーに遭遇するのだろうと思う。

要約

🏗️ The app directory is currently in beta and we do not recommend using it in production.

Getting Started | Next.js

Next.js beta docs にあるように、app/ はまだ production 環境下で使うのはツラく、個人的には<Head />の代わりのhead.tsxを各ページごとに作らないといけない上に挙動がちょっと不安定なのが・・・このブログだけでも移行してみようと思っていたのですが後回しにしました。

移行前の環境

移行前の主なディレクトリ構成

  • src/
    • types/, utils/, hooks/, components/, styles/
  • pages/:基本的に view だけ
    • _app.tsx, _document.tsx
    • index.tsx:投稿一覧
    • 404.tsx
    • feed.xml.tsx, sitempa.xml.tsx
    • entry/
      • [...slug].tsx:投稿詳細 e.g. '/2022/20221114-next13-upgrade'
  • docs/:マークダウンコンテンツ
    • 2022
      • 20221114-next13-upgrade.md
  • public/

やったこと

Next.js や ESLint など各種パッケージのアップデートはガイド通りなので省略する。ただ、Eslint の@typescript-eslint/typescript-estreeがTypeScript@4.9.3を未サポート(2022.11.19 時点)だったので、代わりにTypeScript@4.6.3を入れた。

appdirの有効化と各種.configなど設定ファイルの修正

app/ はまだベータ機能なので注意(2022.11.19 時点)

/** @type {import('next').NextConfig} */
const withBundleAnalyzer = process.env.ANALYZE === 'true'
? require('@next/bundle-analyzer')({ enabled: true })
: (config) => config;
const defaultConfig = {
experimental: {
appDir: true
},
// ~
}
module.exports = withBundleAnalyzer(defaultConfig)
{
"scripts": {
"lint:prettier": "prettier --check {src,app,pages}/**/*.{js,jsx,ts,tsx}",
}
}
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"src/**/*.{js,ts,jsx,tsx}",
"app/**/*.{js,ts,jsx,tsx}",
"pages/**/*.{js,ts,jsx,tsx}"
],
theme: {
extend: {},
},
plugins: [require('@tailwindcss/typography'),],
}

app dir下に{layout,head,page}.tsxを追加

各種設定の変更を終えたのちに、試しに app/ を作成し、Step 2: Creating a Root Layout | Next.jsを参考に、app/下に{layout,head,page}.tsxを作成してみる。なお、v12 環境下でlocalhost:3000/oriverk.dev/を表示していたpages/index.tsxに相当するものは、v13 からapp/page.tsxになった。なので、元からあるpages/index.tsxpages/hoge.tsxと名前を変更しておく。

export default function Page() {
return <div>app/page.tsx</div>
}

npm run dev

image

また、pages/hoge.tsxも共存できている。

image

ここで、pages/hoge.tsxも利用している TailwindCSS の/src/styleds/globals.scss/app/page.tsxでも利用するために、/page/layout.tsxで import し、app/page.tsxclassName="text-red-500"を追加すると

import "../src/styles/globals.scss"
export default function RootLayout({ children }) {
return (
<html lang="ja">
<body>{children}</body>
</html>
);
}

image

Data fetching と Static Genrateの修正

Next.js APIs such as getServerSideProps, getStaticProps, and getInitialProps are not supported in the new app directory.

Data Fetching: Fundamentals | Next.js

v12 まで SG に使用していたgetStaticPathsgetStaticPropsは廃止され、v13 からは代わりにgenerateStaticParamsasync function getData()(任意の関数名)が使われるようになった。

投稿一覧ページ

app/page.tsxは、utils/にあるgetPostsDataで docs/下の md ファイルから frontMatter を抽出したものを表示しているだけなので、ServerComponents(SC)で問題ない。

import { getPostsData } from "@src/utils/markdown/getContentData"
async function getData() {
const { posts } = await getPostsData();
return posts;
}
export default async function Page() {
const posts = await getData();
return (
<>
<div className="text-red-500">app/page.tsx</div>
<pre>
{JSON.stringify(posts, null, 2)}
</pre>
</>
)
}

image

投稿詳細ページ

投稿詳細ページでは/docs/2021/markdonw-guide.mdxといった md ファイルをlocation.origin/entry/2021/markdown-guideのようなパスで表示するために、v12 まではdynamic routes の Catch all routes を利用し、/pages/entry/[...slug].tsxとなっていた。v13 からの app/利用下では/pages/entry/[...slug]/page.tsxとなる。

export default function Page({ params, searchParams }) {
return (
<>
<p>{JSON.stringify(params.slug, null, 2)}</p>
<p>{JSON.stringify(searchParams, null, 2)}</p>
</>
);
}

v13 app/ 環境下での TypeScript が公式で実装途中なために自分で型定義しないといけないと言うこと以外は、基本的に v12 以前と同じ感じ。

image

import { getPostsData } from "@src/utils/markdown/getContentData";
export async function generateStaticParams() {
const { posts } = await getPostsData();
const params = posts.map(({ fileName }) => {
return {
slug: fileName.split("/")
}
})
return params
}
async function getData(params: any) {
const fileName = params.slug.join("/");
const { posts } = await getPostsData();
const post = posts.find((post) => post.fileName === fileName);
return post
}
export default async function Page({ params, searchParams }) {
const post = await getData(params)
return <p>{JSON.stringify(post, null, 2)}</p>
}

image

3rd party パッケージを適切にラッピングする

今回の Next.js v13 から useEffectuseStateなどクライアントで動く ClientComponents (CC) ではuse clientと記載するようになり、記載されてないものはデフォルトでサーバーサイドで動くようになった。ただ、Next.js からは各種パッケージが client で動くかを判別できないので、必要に応じて CC としてラッピングする必要がある。

また、SC から CC に渡せる props にも制限があり、例えば関数 Function や Date オブジェクトなどシリアライズできないモノは直接渡せない。

next-mdx-remote

next-mdx-remote は docs/から mdx?ファイルを読み込んで処理するのに利用している。useEffectなどが内部で使われており、use clientで包む必要がある。

処理した md コンテンツを表示するために用いる<MDXRemote />に渡すものは主に 2 つあり、型は下の様になっている。components の方は先述した SC から CC に渡せないものなので、componentsを含めた形でラッパーを作る必要がある。

type Props = {
compliedSource: string;
components: Record<string, (props: any) => JSX.Element>
}

なので

"use client";
import { MDXRemote, MDXRemoteSerializeResult } from 'next-mdx-remote'
import { MDXComponents } from './mdx-components';
type Props = Pick<MDXRemoteSerializeResult, "compiledSource">;
export const NextMDXRemote: React.FC<Props> = ({ compiledSource }) => (
<MDXRemote components={MDXComponents} compiledSource={compiledSource} />
)

これで無事動くようになった。

その他

next/head<Head />が無くなり、代わりにhead.tsxで指定するようになったのだが挙動が怪しい。。production 環境用にはちょっとまだ時期尚早かな感あった。

参照