個人ブログの Next.js v13 移行でやったことまとめ
Next.js v13 への移行でやったことまとめ
準備
- 基礎となる記事に目を通した -> https://zenn.dev/link/comments/eefa4975aaedaf
マイグレーションガイドを見て一つずつ対応しようかなと思ったけど、記事が長いのでnext dev
で動かして出てきたエラーを潰していく方法にした。とりあえずビルドできるようになったら、見落としやより良いやり方があるか確認するために読む。
ページコンポーネントに対して
- pages にあるファイルを app ディレクトリに移動させる
- 規約 通りに page と layout にコンポーネントを分割する
- getServerSideProps の処理を
async function getData()
に変更する- コンポーネントを async 関数にする
- props ではなくコンポーネントの中で
getData()
の返り値を取得するようにする
- getStaticPath / getStaticProps の処理を
generateStaticParams
とasync function getData()
に移行する- 返り値が
{ props : ...}
ではなくなったのでわかりやすくなった -
{ notFound: true }
にしていたところは、notFound()
関数を使うようにする
- 返り値が
- nested layout 対応
- 全てのページコンポーネントでラップしていたレイアウトをまとめられて便利。全然難しくなかった
既存のコンポーネントに対して
- useRouter を
next/router
からnext/navigation
に置き換え - イベントハンドラがあるコンポーネントに
'use client'
をつけて CC にする- コンポーネントが大きいなら、イベントハンドラがある箇所だけ別コンポーネントだけ切り出してCCとし、親コンポーネントは SC にする
これからの対応
- pages/api は app ディレクトリ未対応だが、ロードマップには入っているのでアナウンスがあったら対応する
- next-transpile-module を使っていると v13 から CSS のビルドに失敗するので、修正対応待ち。これが修正されたら、リリースする
- Storybook で
next/navigation
を使っているコンポーネントを描画できない-
next/router
では 対策ができた けどまだ対応されてない - コメントしたので注視 discussions
-
まとめの中で言及している書き換えの根拠は、このスクラップ内にリンクがあるので適宜参照してみてください。
なお、Next.js v13 の新機能ではあるが、本ブログでは必要なかったこと。主にクライアント側のデータ取得に関して。
- 拡張された fetch の利用 (リクエストをdedupeするとのこと)
- loading.tsx によるローダーの表示
- Suspense の利用
- CC と SC を跨ぐような useContext の利用(そもそも useContext を使ってない)
この辺りは 個人開発で作ったビール画像投稿サイトをv13にすると必要になってくる
公式 Blog をみると簡単かなと思ったけど、触っていくうちに色々やらないといけないことがあるとわかったので書いていく
会社の昼休みとか終業後にやってる。
ブログはこちら
今やってることをピン留めしておく
scrap を作る前は Twitterに色々書いてたので記載
参考になるドキュメント
まずはブログ
次にレンダリングの基礎
CC(Clinet Component) と SC(Server Component) の使い分けの話
データフェッチの話
ここから書き換えの話
use client を付与
エラーが出たコンポーネントに対して、'use client'を付与していく。
すると pathname が null だという違うエラーが出たので次に進む
useRouter 書き換え
Header コンポーネントでuseRouter からpathnameを取得していたコードがエラーに
TypeError: Cannot read properties of null (reading 'pathname')
usePathname を使うコードに書き換える
'use client';
import { usePathname } from 'next/navigation';
export default function Page() {
// When URL is /blog/hello, pathname = '/blog/hello'
// When URL is /dashboard?v=2, pathname = '/dashboard'
const pathname = usePathname();
return <div>{pathname}</div>;
}
ここまでトップページを表示できた。
各ページへのリンクを取得する処理を修正
Header で各ページへのリンクを表示している(Navigation の役割)。トップページ以外のページが存在しないというエラーが出る。
確かにhttp://localhost:3000/profile
へのリクエストが500で返ってきている。
とりあえず pages ディレクトリ にあるページを app/**/page.tsx に移動する。
pages ディレクトリに対応するファイルが残っているとエラーになるので、下記のような感じでとりあえず下記のようなコードを新規ファイルに書いて、元ファイルを削除する
// app/profile/page.tsx
export default function Index() {
return <div>a</div>
}
// コメントアウトした元 page/profile.tsxのコード
// ...
これで Header コンポーネントのエラーが出なくなった。
レスポンスが React のコンポーネントの構造になっている。これが streaming の話(?)かと納得
CSS を読み込む
ここまで無視してたけど、まだ CSS を読み込めていない。
_app.tsx
に以下のコードを書いて CSS をインポートしている。
import '@/styles/index.css'
すると next dev
でエラーが出る。
./src/styles/index.css
Global CSS cannot be imported from files other than your Custom <App>. Due to the Global nature of stylesheets, and to avoid conflicts, Please move all first-party global CSS imports to pages/_app.js. Or convert the import to Component-Level CSS (CSS Modules).
Read more: https://nextjs.org/docs/messages/css-global
Location: pages/_app.tsx
ブログでは Tailwind CSS を使っているので対応するドキュメントを読んでいく
元々設定してるので新しく追加することはそれほどないけど、書いてることを全部対応しても下記のエラーが出る。
何か見落としてるんだろうな
error - ./src/styles/index.css
Module parse failed: Unexpected character '@' (1:0)
You may need an appropriate loader to handle this file type, currently no loaders are configured to process this file. See https://webpack.js.org/concepts#loaders
> @tailwind base;
| @tailwind components;
| @tailwind utilities;
一旦ここまで
あーやっとわかった。
いろいろ試したけど、最終的には next.config.js で読み込んでるプラグインを全部コメントアウトしてみたらCSSが当たった状態でサイトが表示されることを確認できた。
どうも next-transpile-modules が原因らしいが詳しいことはわからない。これをコメントアウトするとビルドできた。
const tm = require('next-transpile-modules')([
'react-share',
'react-use',
'@heroicons/react/outline',
'react-twitter-embed',
])
空配列にするとビルドできた。どれか一つでも使ってるとエラーが出る。うーん、新規プロジェクトでも再現したのでissue立ててみるかな
再現するレポジトリ作って issue 立ててみた。自分の環境のせいだったら逆にいいけど...
CSS の件は issue の返信待ちなので一旦置いておいて別の対応を進める
Next.js が公式で next-transpile-modules 相当の機能をサポートするとのこと
トップページの書き換えができた!
SSG でのデータ取得から、サーバー側でのデータ取得に変更。
SSG の対応どうするんだろと考えてたけど、ドキュメントを読むと fetch() や cookies(), headers()を使わなければ自動的に Static なページになるとのこと。
今はトップページでブログの記事一覧を取得している。トップページでは useRouter を使っていたのでそのまま server component(SC)にはできなかった。
そこで、useRouter を必要としているコンポーネントを別コンポーネントに切り出して client component (CC)としたところ、トップページは SC として扱えるようになった。
そこで、データ取得方法を切り替えた。import は省略
旧。SSG。
type Props = {
posts: ComponentProps<typeof PostCardList>['posts']
}
const Index: NextPage<Props> = ({ posts }) => {
return (
<Layout title="パンダのプログラミングブログ">
<SEOHead path="/" title="トップページ" type="website" />
<Container size="md">
<PostCardList posts={posts} />
<div className="mt-20 flex justify-end">
<BlogListButton />
</div>
</Container>
</Layout>
)
}
export default Index
export const getStaticProps = async () => {
const allPosts = getAllPostsForCard()
const postsFromFeeds = await getPostsFromFeeds()
const sorted = allPosts
.concat(postsFromFeeds)
.sort((post1, post2) => (post1.date > post2.date ? -1 : 1))
.slice(0, 16)
return {
props: { posts: sorted },
}
}
新。Static
async function getPosts() {
const allPosts = getAllPostsForCard()
const postsFromFeeds = await getPostsFromFeeds()
return allPosts
.concat(postsFromFeeds)
.sort((post1, post2) => (post1.date > post2.date ? -1 : 1))
.slice(0, 16)
}
export default async function Index() {
const posts = await getPosts()
return (
<>
<SEOHead path="/" title="トップページ" type="website" />
<Container size="md">
<PostCardList posts={posts} />
<div className="mt-20 flex justify-end">
<BlogListButton />
</div>
</Container>
</>
)
}
開発ビルドでも初回アクセス時だけデータを取得して、ページ遷移で戻ってきても再取得してない。いい感じ。Remix 感ある。書き方だけだけど。
そういえば fetch 以外で取得したデータを定期的に revalidate したい時はどうするんだろ?
pages/_app.js and pages/_document.js have been replaced with a single app/layout.js root layout. Learn more.
_app.tsx, _document.tsx はなくなったので、root の layout に meta タグやラッパーを移動させる
reportWebVitals はどうなるんだろ?今は見てないし一旦外すのでいいか
Google Analytics のためのコードを移行する。コード全部はブログの下の方に掲載しているもの。
書き換え対象はこちら。
export const usePageView = () => {
const router = useRouter()
useEffect(() => {
if (!existsGaId) {
return
}
const handleRouteChange = (path: string) => {
pageview(path)
}
router.events.on('routeChangeComplete', handleRouteChange)
return () => {
router.events.off('routeChangeComplete', handleRouteChange)
}
}, [router.events])
}
next/navigation に events は定義されてないからどうすればいいかな?
next/router をそのまま使えばいいかと思ったけど、v13 の app ディレクトリではもう使えなくなってた。
The useRouter hook imported from next/router is not supported in the app directory but can continue to be used in the pages directory.
Next.js の issue にも立ってないみたい?
と思ったら discussions が立ってた
そこから辿ったらワークアラウンドが書かれてた。v13では、Route intercept というのがロードマップに入っているけどこれかどうかはわからない
'use client'
import {usePathname, useSearchParams} from 'next/navigation'
function useNavigationEvent() {
const pathname = usePathname()
const searchParams = useSearchParams()
useEffect(() => {
const url = pathname + searchParams.toString()
sendSomewhere(url)
}, [pathname, searchParams])
}
GoogleAnalytics.tsx を CC として作って、root の layout.tsx から読み込むようにした。
'use client'
import { usePathname, useSearchParams } from 'next/navigation'
import Script from 'next/script'
import { useEffect } from 'react'
import { existsGaId, GA_ID, pageview } from '@/lib'
const usePageView = () => {
const pathname = usePathname()
const searchParams = useSearchParams()
useEffect(() => {
if (!existsGaId) {
return
}
const url = pathname + searchParams.toString()
pageview(url)
}, [pathname, searchParams])
}
const GoogleAnalytics = () => {
usePageView()
return (
<>
{/* Google Analytics */}
{existsGaId && (
<>
<Script
id="ga-url"
defer
src={`https://www.googletagmanager.com/gtag/js?id=${GA_ID}`}
strategy="afterInteractive"
/>
<Script
id="ga-script"
defer
dangerouslySetInnerHTML={{
__html: `
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${GA_ID}');
`,
}}
strategy="afterInteractive"
/>
</>
)}
</>
)
}
export default GoogleAnalytics
layout.tsx
import { GoogleAnalytics, Layout, Meta, SEOHead } from '@/components'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<head>
<Meta />
<SEOHead path="/" title="トップページ" type="website" />
<GoogleAnalytics />
</head>
<body>
<Layout title="パンダのプログラミングブログ">{children}</Layout>
</body>
</html>
)
}
エラー出て修正して、を繰り返してたらSCとCCの使い方がわかってきた。
layout は SC にしておいて、layout が呼び出すコンポーネントを CC にすればいい。
CC にするかどうかの基準は、状態が変わるかどうか。例えば、useState、useEffect を使うものは必ず CC になる。onClick があればこれも必ず CC にする。
ポイントは、サーバーサイドでレンダリングできる固定のものか、ブラウザ上のイベントに応じて状態が変わり、DOMが書き換えられるかどうかの違いと理解している。
SC にできるものはクライアント側のJSの配信が減るので、積極的にSCにしていく。反対に、状態を扱うものはCCにする。SC/CCの境界ができることにより、コンポーネントをより小さく分割しようとする力が働く。
この力はフレームワーク上の仕組みで違反するとエラーになるため、「Presentation / Container コンポーネント」の慣習的な分割より強力に働く。
このエラーを解消するためにコンポーネントを小さく分割する。結果的に配信されるJSが減り初期表示のパフォーマンスが向上する。 この点ではうまいこと考えられてるなと思う。もちろんこれだけじゃないけど(data fetch、water fall の話とか)
サーバーコンポーネントっていろんな課題(配信されるJSのサイズが大きい、データ取得どこでやるか、hydrationが遅い(?)、データ取得の warter fall を避ける etc.)を一つの手段で解決してるからややこしく思えるんだな。
next/router -> next/navigation への移行
asPath が使えない。asPath はパスとクエリを同時に取得できるプロパティだった。
v13 では、パスは usePathname()、クエリは useSearchParams() を使って取得する。
同時に取得するなら両方の hooks を使う必要がある。
ページネーションの処理で asPath を使っていたので、これを書き換える必要がある。
import { useRouter } from 'next/router'
import { useSearchParams } from '@/hooks'
export const usePagination = (postCount: number) => {
const router = useRouter()
const asPath = router.asPath
const page = useSearchParams('page')
// 1ページ目にいる時の処理
if (!page || page === '1') {
const path = asPath.replace('?page=1', '')
return { hasNext: postCount > 20, goNext: () => router.push(`${path}?page=2`), hasPrev: false, goPrev: () => null }
}
// これ以下は2ページ目以降にいるときの処理
const hasNext = postCount / 20 > Number(page)
const goNext = () => {
const path = asPath.replace(`page=${page}`, `page=${Number(page) + 1}`)
router.push(path)
}
const hasPrev = postCount > 20
const goPrev = () => {
const path = asPath.replace(`page=${page}`, `page=${Number(page) - 1}`)
router.push(path)
}
return { hasNext, goNext, hasPrev, goPrev }
}
この hooks はテストを書いていないので、リファクタする前にテストを書きたい。
しかし、テストでは next/router では export されていた singletonRouter が next/navigation からは使えないため、どうやってテストすればいいんだろうか?
next-router-mock ではまだ議論されていない
とりあえず Next.js 側の discussions に立てた。反応がくるかはわからない
とりあえず next/router
でテストを書いた。これをもとにブラウザでデグレチェックしつつ、next/navigation
に書き換える
import { act } from '@testing-library/react'
import { renderHook } from '@testing-library/react-hooks'
import mockRouter from 'next-router-mock'
import singletonRouter from 'next/router'
import { expect, describe, test } from 'vitest'
import { usePagination } from '../usePagination'
describe('usePagination', () => {
beforeEach(() => {
mockRouter.setCurrentUrl('/posts')
})
test('1ページ目にいて、記事数は10のとき、前後に移動できない', () => {
singletonRouter.push('/posts')
const { result } = renderHook(() => usePagination(10))
expect(result.current.hasNext).toBe(false)
expect(result.current.hasPrev).toBe(false)
})
test('1ページ目にいて、記事数は30のとき、次に移動できる', () => {
singletonRouter.push('/posts')
const { result } = renderHook(() => usePagination(30))
expect(result.current.hasNext).toBe(true)
expect(result.current.hasPrev).toBe(false)
act(() => {
result.current.goNext()
})
expect(result.current.hasNext).toBe(false)
expect(result.current.hasPrev).toBe(true)
expect(singletonRouter).toMatchObject({ query: { page: '2' } })
})
test('2ページ目にいて、記事数は50のとき、前後に移動できる', () => {
singletonRouter.push('/posts?page=2')
const { result } = renderHook(() => usePagination(50))
expect(result.current.hasNext).toBe(true)
expect(result.current.hasPrev).toBe(true)
act(() => {
result.current.goPrev()
})
expect(result.current.hasNext).toBe(true)
expect(result.current.hasPrev).toBe(false)
expect(singletonRouter).toMatchObject({ query: { page: '1' } })
act(() => {
result.current.goNext()
})
act(() => {
result.current.goNext()
})
expect(result.current.hasNext).toBe(false)
expect(result.current.hasPrev).toBe(true)
expect(singletonRouter).toMatchObject({ query: { page: '3' } })
})
})
next/navigation
で書き換えた。ちょっとリファクタも入れた。多分これで大丈夫
import { useRouter, usePathname, useSearchParams } from 'next/navigation'
export const usePaginationNew = (postCount: number) => {
const router = useRouter()
const pathname = usePathname()
const params = useSearchParams()
const page = params.get('page')
const getPath = (addend: 1 | -1) => `${pathname}?page=${Number(page) + addend}`
// 1ページ目にいる時の処理
if (!page || page === '1') {
return {
hasNext: postCount > 20,
goNext: () => router.push(`${pathname}?page=2`),
hasPrev: false,
goPrev: () => null
}
}
// これ以下は2ページ目以降にいるときの処理
const hasNext = postCount / 20 > Number(page)
const hasPrev = postCount > 20
const goNext = () => {
router.push(getPath(1))
}
const goPrev = () => {
router.push(getPath(-1))
}
return { hasNext, goNext, hasPrev, goPrev }
}
これでnext/router
を使っていたためエラーになってたページが表示された。めでたし
getStaticPaths、getStaticProps を使っていた個別のSSGページの対応
記事の詳細ページのパスが /posts/[slug]
で getStaticPaths を使っているので書き換えをする。
export async function generateStaticParams() {
const posts = await getPosts();
return posts.map((post) => ({
slug: post.slug,
}));
}
使う方はこんな感じ。
export default function Page({ params }) {
const { slug } = params;
return ...
}
ただ、generateStaticParams の返り値に型がつけられない。issue が立ってる
自分はとりあえず手で型をつけた。
export default async function PostPage({ params }: { params: { slug: string }}) { ...}
データの流れは以下のように変わった
v12まで: getStaticPath
-> getStaticProps
(slugを受け取る) -> PageComponent
v13: generateStaticParams
-> PageComponent (slugを受け取る)
v13v では、PageComponent が受け取った slug を async function getData()
などSCで使えるデータ取得関数に渡す。slug を React コンポーネントが直接受け取れるようになったのが変更点。
それでもエラーが出た。原因はファイル名。app/posts/[slug].tsx
を置いていたが、正しくはapp/posts/[slug]/page.tsx
だった。通りで console.log を仕込んでもログが出ないわけだ。
ページは読み込めたがエラーがでる。
Error: Event handlers cannot be passed to Client Component props.
<... href=... onClick={function} children=...>
onClick がある子コンポーネントに降りていき、use client
をつける。とりあえず動かす。動いた後にイベントハンドラが必要なコンポーネントはさらに小さく切り出せるか検討する。すると、その中で親コンポーネントはSCに、イベントハンドラが必要なコンポーネントはCCに切り分けられる。
'use client' をつけたらページが表示された
Not Found の404ページを表示する
ブログ個別記事で、記事が存在しないURLにアクセスされたときに Not found を表示する挙動を v13 向けに書き換える。
今までは、getStaticProps で { notFound: true } を返して実現していた。
export async function getStaticProps(context) {
const res = await fetch(`https://.../data`)
const data = await res.json()
if (!data) {
return {
notFound: true,
}
}
return {
props: { data }, // will be passed to the page component as props
}
}
これからは、 notFound
という関数を使う。これはnext/navigation
に入っている。この関数が実行されると、NEXT_NOT_FOUND
という例外が投げられてレンダリングが止まる。
not-found.tsx を作っておけば、このページが表示される。
// not-found.tsx
export default function NotFound() {
return "Couldn't find requested resource"
}
ここまでやったらビルドに成功した。
info - Generating static pages (93/93)
info - Finalizing page optimization
Route (app) Size First Load JS
┌ λ / 0 B 0 B
├ λ /dev 277 B 107 kB
├ λ /policy 278 B 107 kB
├ λ /posts 278 B 107 kB
├ ● /posts/[slug] 262 B 107 kB
├ ├ /posts/the-efficient-way-to-make-slides
├ ├ /posts/three-types-of-value-object
├ ├ /posts/translate-agile-manifest-casually
├ └ [+80 more paths]
├ λ /posts/hatenablog 277 B 107 kB
├ λ /posts/note 278 B 107 kB
├ λ /posts/zenn 278 B 107 kB
└ λ /profile 277 B 107 kB
+ First Load JS shared by all 65.3 kB
├ chunks/17-688acde8960a4e22.js 63 kB
├ chunks/main-app-5dbe79f35bd947e6.js 200 B
└ chunks/webpack-496bd1f6f1d69ccc.js 2.1 kB
Route (pages) Size First Load JS
┌ ○ /404 179 B 82.6 kB
├ λ /api/feed 0 B 82.5 kB
└ λ /api/posts/[slug] 0 B 82.5 kB
+ First Load JS shared by all 82.5 kB
├ chunks/main-5fb8b04298eb25ee.js 80.2 kB
├ chunks/pages/_app-9f5490aa3d56632f.js 192 B
└ chunks/webpack-496bd1f6f1d69ccc.js 2.1 kB
λ (Server) server-side renders at runtime (uses getInitialProps or getServerSideProps)
○ (Static) automatically rendered as static HTML (uses no initial props)
● (SSG) automatically generated as static HTML + JSON (uses getStaticProps)
First Load JS が減ってる気がするので後で確認する。
ビルド成功したし、next start
でもちゃんと動いているので、あとは以下の issue が解決したらリリースできる!テストも通ってるし。
v12 でビルドしたら、確かに First Load JS が 63kb ほど減ってた
Route (pages) Size First Load JS
┌ ● / (612 ms) 616 B 170 kB
├ /_app 0 B 170 kB
├ λ /404 351 B 170 kB
├ λ /api/feed 0 B 170 kB
├ λ /api/posts/[slug] 0 B 170 kB
├ ○ /dev 447 B 170 kB
├ ● /policy 508 B 170 kB
├ ● /posts (713 ms) 738 B 170 kB
├ ● /posts/[slug] (26659 ms) 446 B 170 kB
├ └ css/969c3a144f2e23d6.css 4.35 kB
├ ├ /posts/nextjs-storybook-typescript-errors (1022 ms)
├ ├ /posts/translate-agile-manifest-casually (886 ms)
├ ├ /posts/use-commitizen-commit-prefix (853 ms)
├ ├ /posts/bengo4com-library-frontend (747 ms)
├ ├ /posts/hygen-react (728 ms)
├ ├ /posts/renovate-gitlab (649 ms)
├ ├ /posts/monolith-note (628 ms)
├ └ [+76 more paths]
├ ● /posts/hatenablog (655 ms) 335 B 170 kB
├ ● /posts/note (557 ms) 329 B 170 kB
├ ● /posts/zenn (588 ms) 330 B 170 kB
└ ○ /profile 334 B 170 kB
+ First Load JS shared by all 176 kB
├ chunks/framework-3b5a00d5d7e8d93b.js 45.4 kB
├ chunks/main-5882bdb81df34e52.js 27.9 kB
├ chunks/pages/_app-139bbacceae3e303.js 95.6 kB
├ chunks/webpack-8fa1640cc84ba8fe.js 750 B
└ css/b55b38315e73e24f.css 6.56 kB
λ (Server) server-side renders at runtime (uses getInitialProps or getServerSideProps)
○ (Static) automatically rendered as static HTML (uses no initial props)
● (SSG) automatically generated as static HTML + JSON (uses getStaticProps)
画像で