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/

プログラミングをするパンダプログラミングをするパンダ

ここから書き換えの話


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)

画像で