Next.js v12 から v13 with app dir に移行する
複数ある 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.
Next.js beta docs にあるように、app/
はまだ production 環境下で使うのはツラく、個人的には<Head />
の代わりのhead.tsx
を各ページごとに作らないといけない上に挙動がちょっと不安定なのが・・・このブログだけでも移行してみようと思っていたのですが後回しにしました。
移行前の環境
- Next.js v12
- TypeScript
- マークダウン処理:hashicorp/next-mdx-remote
- CSS: TailwindCSS
- 検索:Algolia/react-instantsearch/packages/react-instantsearch-hooks-web
移行前の主なディレクトリ構成
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
- 2022
public/
やったこと
Next.js や ESLint など各種パッケージのアップデートはガイド通りなので省略する。ただ、Eslint の@typescript-eslint/typescript-estree
がTypeScript@4.9.3を未サポート(2022.11.19 時点)だったので、代わりにTypeScript@4.6.3を入れた。
app
dirの有効化と各種.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.tsx
はpages/hoge.tsx
と名前を変更しておく。
export default function Page() { return <div>app/page.tsx</div>}
npm run dev

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

ここで、pages/hoge.tsx
も利用している TailwindCSS の/src/styleds/globals.scss
を/app/page.tsx
でも利用するために、/page/layout.tsx
で import し、app/page.tsx
にclassName="text-red-500"
を追加すると
import "../src/styles/globals.scss"
export default function RootLayout({ children }) { return ( <html lang="ja"> <body>{children}</body> </html> );}

Data fetching と Static Genrateの修正
Next.js APIs such as getServerSideProps, getStaticProps, and getInitialProps are not supported in the new app directory.
v12 まで SG に使用していたgetStaticPaths
とgetStaticProps
は廃止され、v13 からは代わりにgenerateStaticParams
やasync 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> </> )}

投稿詳細ページ
投稿詳細ページでは/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 以前と同じ感じ。

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>}

3rd party パッケージを適切にラッピングする
今回の Next.js v13 から useEffect
や useState
などクライアントで動く 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 環境用にはちょっとまだ時期尚早かな感あった。