Open15

個人ブログの Next.js v13 移行でやったことまとめ

ピン留めされたアイテム

Next.js v13 への移行でやったことまとめ

準備

マイグレーションガイドを見て一つずつ対応しようかなと思ったけど、記事が長いのでnext devで動かして出てきたエラーを潰していく方法にした。とりあえずビルドできるようになったら、見落としやより良いやり方があるか確認するために読む。

ページコンポーネントに対して

  • pages にあるファイルを app ディレクトリに移動させる
    • 規約 通りに page と layout にコンポーネントを分割する
  • getServerSideProps の処理をasync function getData() に変更する
    • コンポーネントを async 関数にする
    • props ではなくコンポーネントの中でgetData()の返り値を取得するようにする
  • getStaticPath / getStaticProps の処理を generateStaticParamsasync 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.js v13 の新機能ではあるが、本ブログでは必要なかったこと。主にクライアント側のデータ取得に関して。

  • 拡張された fetch の利用 (リクエストをdedupeするとのこと)
  • loading.tsx によるローダーの表示
  • Suspense の利用
  • CC と SC を跨ぐような useContext の利用(そもそも useContext を使ってない)

この辺りは 個人開発で作ったビール画像投稿サイトをv13にすると必要になってくる

https://beerbreak.info/

公式 Blog をみると簡単かなと思ったけど、触っていくうちに色々やらないといけないことがあるとわかったので書いていく

会社の昼休みとか終業後にやってる。

ブログはこちら
https://panda-program.com/

今やってることをピン留めしておく

ここから書き換えの話


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

https://beta.nextjs.org/docs/api-reference/use-pathname

ここまでトップページを表示できた。


各ページへのリンクを取得する処理を修正

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 を使っているので対応するドキュメントを読んでいく

https://beta.nextjs.org/docs/styling/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 立ててみた。自分の環境のせいだったら逆にいいけど...

https://github.com/martpie/next-transpile-modules/issues/283

CSS の件は issue の返信待ちなので一旦置いておいて別の対応を進める


Next.js が公式で next-transpile-modules 相当の機能をサポートするとのこと

https://github.com/martpie/next-transpile-modules/issues/280#issuecomment-1292615030


トップページの書き換えができた!

SSG でのデータ取得から、サーバー側でのデータ取得に変更。

SSG の対応どうするんだろと考えてたけど、ドキュメントを読むと fetch() や cookies(), headers()を使わなければ自動的に Static なページになるとのこと。

https://beta.nextjs.org/docs/rendering/static-and-dynamic-rendering

今はトップページでブログの記事一覧を取得している。トップページでは 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 したい時はどうするんだろ?

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 は定義されてないからどうすればいいかな?
https://beta.nextjs.org/docs/api-reference/use-router


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.

https://beta.nextjs.org/docs/upgrade-guide#step-5-migrating-routing-hooks

Next.js の issue にも立ってないみたい?

と思ったら discussions が立ってた
https://github.com/vercel/next.js/discussions/42016

そこから辿ったらワークアラウンドが書かれてた。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])
}

https://github.com/vercel/next.js/discussions/41745#discussioncomment-3987025


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の境界ができることにより、コンポーネントをより小さく分割しようとする力が働く。

https://beta.nextjs.org/docs/rendering/server-and-client-components

この力はフレームワーク上の仕組みで違反するとエラーになるため、「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 ではまだ議論されていない

https://github.com/scottrippey/next-router-mock

とりあえず Next.js 側の discussions に立てた。反応がくるかはわからない
https://github.com/vercel/next.js/discussions/42527


とりあえず 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,
  }));
}

https://beta.nextjs.org/docs/api-reference/generate-static-params#generatestaticparams

使う方はこんな感じ。

export default function Page({ params }) {
  const { slug } = params;

  return ...
}

ただ、generateStaticParams の返り値に型がつけられない。issue が立ってる

https://github.com/vercel/next.js/issues/41884

自分はとりあえず手で型をつけた。

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 が解決したらリリースできる!テストも通ってるし。

https://github.com/martpie/next-transpile-modules/issues/283


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)

画像で

ログインするとコメントできます