🆙

Next.js を v3 から v13 に一気にアップデートした話

2023/08/14に公開

2023年夏休みの自由研究がてら、約6年メンテナンスされていなかった Next.js で作られたサイトの開発環境をアップデートしました。Next.js に関しては、なんと v3 から v13 に一気にアップデートでした。

ただやっただけだともったい無いので、せっかくなので記事に残そうと思います。同じように Next.js で作られたサイトのメンテナンスを怠っている人に届いて、何か少しでも助けになれれば幸いです。

背景

まず、アップデートをしようと思った最初のきっかけは、M1/M2 Mac で Node.js を使って普通に開発ができないことでした。6年間メンテされてなかったこともあり、この環境の Node.js のバージョンは v8.4.0 です。(当時はもちろん最新でした😂)

Node.js v16 未満のバージョンを M1/M2 Mac にインストールして使うには、以下の記事のように Rosetta を使用する必要があります。

まあ、このやり方でも開発はできるのですが、せっかく Apple Silicon の Mac を使っているのに Intel Mac 用アプリ向けの Rosetta はできるだけ使いたくないなという気持ちがありました。

もうひとつの理由はセキュリティです。古い Node.js を使っているので、使用しているライブラリも古いものが多く、その分脆弱性もかなりあり、セキュリティリスクが高い状態になっていました。

npm audit もしくは yarn audit でインストールされているライブラリの脆弱性を調査することができます。以下はアップデート前の結果です。510 の脆弱性があり、High のものが 233Critical59 もあり、かなりやばい状態です。

アップデート前の yarn audit 実行結果

まあ、あとは古い Next.js を触るモチベーションも湧かないなど、いろんな理由もあり、Node.js やライブラリをアップデートし、セキュリティリスクも限りなく 0 に近い状態を目指すことにしました。

以下は作業の記録です。
(パッケージ管理には npm を使用します。)

ちなみに

6年前というと、僕が React Static というフレームワークを使って、初めて React でサイトを作った時期です。以前 jQueryを卒業したかった僕がReact StaticでReactをイチから学んでWebサイトを作った話 という記事も書きましたが、今はもう React Static はメンテナンスモードに入っております😇

それに比べて、というわけでもないですが、Next.js は今でもしっかりアップデートされ続けていて、安心感持てますね。

作業手順

0. ChatGPT に聞いてみた

まずは、ワンチャン何か裏技みたいな方法がないか、ChatGPT に聞いてみました。

ChatGPT からの回答スクリーンショット

ですよね。僕もそれが正しいやり方だと思ってました。

しかし、v3 から v13 までのリリースノートをすべてチェックして、段階的にアップデートするのはさすがに時間がかかります。できれば夏休み(お盆休み)の間に終わらせたいと考えていたので、これは逆に最終手段にして、もう作り直す覚悟で一気に最新バージョンに上げようと決めました。

幸い、このサイトは15ページ程度の小規模なサイトだったので、作り直すという選択肢も現実的です。大規模サイトだったらこうはいかないと思うので、やはりこまめにアップデートしなきゃですねぇ。

1. Node.js をアップデート

まずは、Node.js が古いままだと Rosetta を使わないといけないので、Node.js を v8.4.0 から、最新の LTSv18.17.1 にアップデートしました。(2023/08/14 現在)

asdf の場合は .tool-versionsnodenv の場合は .node-version ファイルを更新します。

.tool-versions
nodejs 18.17.1

その後、該当の Node.js を上記ツールなどを使ってインストールします。

2. Next.js や React など主要なライブラリを最新にアップデート

もちろん、これだけだと npm install は成功しません。インストールされているライブラリに 古い fsevents が使われていて、それがインストールした Node.js に対応しておらず node-gyp 周りのエラーが出ます。

このエラーを解消すべく、以下の主要なライブラリをすべて最新版にアップデートしました。

いや、このバージョンの変化を見ると、もう別物と言ってもいいですね。
他にもいくつもライブラリは入っていましたが、必要のないライブラリはできるだけ削除していきたいと考えていたので、まずは絶対に必要な分だけアップデートしています。

上記のアップデートをすることで、まずは npm install が成功する状態になりました。

npm install 成功

3. ディレクトリ構成を変更

僕はあまり以前の Next.js のことは詳しくないのですが、今とディレクトリ構成が違っていたのかな?今は開発用ファイルは src ディレクトリ、静的ファイルは public ディレクトリという構成に基本なっていると思いますが、 このプロジェクトではルートディレクトリに componentspages などが置かれている構成になってました。

僕自身 src ディレクトリで開発することに慣れていることもあり、ルートにすべてあると見通しが悪かったので、開発ファイルは全て src ディレクトリに移しました。

また、このプロジェクト特有なのかわかりませんが、静的ファイル用のディレクトリが static になっていたので、Next.js の静的ファイル配信のルールに従って public ディレクトリに変更しました。

この時 /static/images/... のようになっているパスを /images/... に変更します。

以下のような、Next.js のディレクトリ構成になっています。

/
 |- /.next
 |- /node_modules
 |- /out
 |- /public
 |- /src
 |   |- /components
 |   |- /containers
 |   |- /pages
 |- .gitignore
 |- .eslintrc
 |- next.config.js
 |- package-lock.json
 |- package.json
 |- README.md

追記:2023/08/19

はてなブックマーク で「src ディレクトリがあるのが基本だっけ?」というコメントをいただきました。僕もそこまで深く調査をせずに書いてしまったので、コメントとてもありがたいです。

おっしゃる通り create-next-app --src-dir を使うことで src ディレクトリを使うようになるので、その点では src ディレクトリを使わない方がデフォルトとも言えますが、npx create-next-app を実際に叩くと、Next.js プロジェクトのセットアップのためのプロンプトが表示されますが、その中の Would you like to use src/ directory? の質問では YES がデフォルトで選ばれているので、その点だと src ディレクトリを使う方がデフォルトと言えるのかもしれません。

YES がデフォルトで選ばれている

なので、今のところ src ディレクトリあり/なしのどちらが基本的なディレクトリ構成だとも言いづらいので、上の文章は打ち消し線を入れさせてもらいました。僕の勝手な解釈で書いてしまうと、見た方が勘違いしてしまう可能性があるので、本当気をつけないとですね。改めて、コメントありがとうございました!

4. next.config.js を書き換え

準備は整ったので、次は next コマンドで開発サーバーが起動するところを目指します。

next.config.js に関しては調べたところ、そこまで大きく書き方も変わっておらず、アップデートに苦労しませんでした。元々そんなに config ががっつり書かれていなかったので、助かりました。

変更したのは以下の2点です。

  • assetPrefix -> basePath
    • assetPrefix は今の Next.js のバージョンでも使えそうですが、basePath というオプションが v9.5 から追加されていました。今回のプロジェクトの場合はまさに /doc のようなサブパスでのサイトでしたので、basePath を使うよう変更します。
next.config.js
basePath: '/xxx',
  • compiler option に styled-components を指定

このプロジェクトでは styled-components を使っているので、compiler オプションに styled-component を指定します。

next.config.js
compiler: {
  styledComponents: true,
},

以前は babel-plugin-styled-components を使ってコンパイルしていましたが、今の Next.js では babel-plugin-styled-components が移植されており、このオプションを設定するだけでコンパイルしてくれます。ありがてぇ。

We're working to port babel-plugin-styled-components to the Next.js Compiler.
First, update to the latest version of Next.js: npm install next@latest. Then, update your next.config.js file:

5. babel を削除

先ほど babel-plugin-styled-components が必要なくなったので削除しましたが、Next.js も v12 からデフォルトで Rust製の SWC を使うようになっており、Babel は必要ありません。また、それ以外の Babel 関係の設定(.babelrc)やプラグインも必要なさそうだったので、一旦削除して、もし必要があればまたインストールするというやり方でいきました。

babel-plugin-module-resolver という import のパスにエイリアスを使うためのプラグインが入っていたので、削除にあたり一度パスをすべて相対パスに置換しました。

エイリアスの機能は Next.js だと jsconfig.json もしくは tsconfig.jsonpaths オプションを使って設定できるようサポートされているので、後ほど TypeScript を導入後に設定しています。

6. Flow の型付けを削除

このプロジェクトでは、静的型付けに Flow を使っていました。もちろん Flow は現在もメンテされていますが、僕自身が Flow に詳しくなく、TypeScript の方が書き慣れているのもあり、TypeScript を使うことにしました。

なので、まずは .flowconfigflow-typed ディレクトリを削除し、コンポーネントなどの js ファイルに書かれていた型付けを一旦コメントアウトしました。また、js ファイルの先頭に書かれた // @flow も全部削除しました。

型付けをコメントアウトしたのは、後で TypeScript を導入した時に、そのコンポーネントがどういう型を持っているかをパッとわかりやすくしたかったからです。実際そのおかげでかなり効率よく導入できたと思います。

// type IProps = {
//   text?: string
// }

この場合、型が string の text という Props があるのがパッとわかりますから、あとは TypeScript の書き方で書き換えるだけという作業になります。

この作業が終わったら flow-typed などの Flow 関係のライブラリも削除します。

7. styled-components 周りのエラー解消

styled-components も v2 から v6 に上げているので、かなり変更点があると思っていましたが、基本的に変更する必要があったのは v4 で変更された分でした。

  • injectGlobal -> createGlobalStyle に置き換え

グローバルな CSS を書くための関数が injectGlobal から createGlobalStyle に変更されました。以下のような書き方に変更します。

globalStyles.js
import { injectGlobal } from 'styled-components';
export default injectGlobal`
...
`
// を以下に変更
import { createGlobalStyle } from 'styled-components';
const GlobalStyles = createGlobalStyle`
...
`

この GlobalStyles はコンポーネント化されるので、App コンポーネントなどで読み込む必要があります。

_app.js
import GlobalStyles from "../components/GlobalStyles";

const App = ({ children }: IProps) => (
  <ThemeProvider theme={theme}>
    <GlobalStyles />
    {children}
  </ThemeProvider>
);
  • .extendstyled() に置き換え

コンポーネントのスタイルを拡張するための .extend が v4 で廃止されたので、代わりに styled() を使う書き方に変更します。

const StyledHoge = Hoge.extend`
...
`;
// を以下に変更
const StyledHoge = styled(Hoge)`
...
`;
  • .withComponent -> as prop に置き換え

同じく .withComponent という、コンポーネントを別のタグで使用するための機能が廃止され、代わりに as という Props が追加されたので、そちらを使うよう変更します。

const StyledTableColHeader = StyledTableCol.withComponent('th').extend`
...
`;
// を以下に変更
const StyledTableColHeader = styled(StyledTableCol)`
...
`;
<StyledTableColHeader as="th">...</StyledTableColHeader>

8. pages/_document.js のエラー解消

Next.js v3 の時になかったのかどうかまでは調べていませんが、_document.jsMissing component: <Html /> という Warning が出ているので、_document.js の中の HTML の html タグを Next.js の Html コンポーネントを使うように変更します。

Html コンポーネントを使わないと、正しくレンダリングされなくなる可能性があるので、よほどの理由のない限りは Html コンポーネントを使った方が良さそうです。

Next.js v13 から next/linkpassHref 属性を使った書き方では <a> タグが入れ子になってしまい Hydration エラーが出るようになっています。

なので、passHref 属性を使っている LinkpassHref を使わない書き方に変更します。

const StyledLink = styled.a`
...
`
...
<Link href="/hoge/" passHref>
  <StyledLink>{children}</StyledLink>
</Link>
// を以下に変更
const StyledLink = styled(Link)`
...
`
...
<StyledLink href="/hoge/">{children}</StyledLink>

この時点で、まだコンソールにエラーは出ていたり、崩れはありますが、next コマンドでひとまず開発サーバーが立ち上がり http://localhost:3000/ でサイトが表示されるようになりました🎉

10. recompose を React Hooks に置き換え

このサイトが作られたときはまだ React Hooks がなかったので、HoC を使ってロジックを書くのが主流でした。recompose は HoC を扱うためのライブラリです。

今は recompose の作者の方も Hooks を使うことを推奨していますので、recompose を使って HoC で書いている部分を React Hooks に置き換えます。

今回のプロジェクトではそこまでがっつり HoC で書かれている部分はなかったので、そこまで苦労しませんでした。例えば next/routerwithRouter()recompose を使っていた部分は以下のように置き換えました。

import { compose } from 'recompose';
import { withRouter } from 'next/router';
...
export default compose(
  withRouter,
)(Hoge);
// を以下に変更
import { useRouter } from "next/router";
...
const Hoge = () => {
  const router = useRouter();
  ...
}
export default Hoge;

僕もそうですが、そこまで recompose に詳しくない人にとっては、複雑なロジックの置き換えは大変だと思います。こういう時は ChatGPT に頼れば、ある程度の道を示してくれるので本当便利です。(機密情報を含んだコードを ChatGPT に流さないように注意してくださいね。)

recompose を使ったコードはすべて削除できましたので、ライブラリも削除します。

11. react-redux などの状態管理を React Hooks で対応

小規模なサイトですが、一部 react-redux を使って状態管理をしていました。ただ、タブのアクティブ状態を管理しているくらいだったので、useStateuseContext を使って状態を管理する Custom Hooks と Provider コンポーネントを作るやり方にしました。

ここでは具体的にどういうコードに置き換えたかは書きませんので、気になる方は以下の記事などを参考にされてください。

これで react-redux などの状態管理周りのライブラリも削除できます。

ここまでくれば、ほぼ今まで通りに動作するようになります。ここからはそれ以外のツールのアップデート作業です。

12. ESLint を最新バージョンに。

ESLint は v4 が入っていたので、最新の v8 を使うようにします。

ルールは eslint-config-airbnb を使っていたのですが、最近更新が止まっているようなので、Next.js 向けの eslint-config-next を使うようにしました。

eslint-config-next を使う設定を .eslintrc で設定してあげるだけです。 Next.js は Core Web Vitals に関するルールが追加された next/core-web-vitalsの利用を推奨しているようなのですが、早くアップデートを完了したかったのもあり、今回は使用していません。

.eslintrc
{
  "extends": "next",
}

Next.js の ESLint を使うことで、@next/next/no-img-element のエラーが大量に出ることになります。これは next/imageImage コンポーネントを使ってね、ということなのですが、このプロジェクトはビルドしたファイルを export して利用する、いわゆる SSG なので、この Image コンポーネントが使っている Image Optimization API は export されたファイルでは使えません。

なので、img タグはそのままで、この @next/next/no-img-element を無効にするルールを .eslintrc で設定します。

.eslintrc
{
  "extends": "next",
  "rules": {
    "@next/next/no-img-element": "off"
  }
}

13. jest を最新バージョンにアップデート

jest はひとつの単体テストくらいしか使っていなかったのですが、せっかくなのでアップデートします。v20 -> v29 へのアップデートです。

公式ドキュメント通り、next/jest を使うやり方で、必要なライブラリをインストールし、jest.config.js を作って設定します。

公式ドキュメントと同じやり方で設定しただけなので、詳しい解説は省略します。

14. その他必要なライブラリをアップデート

上記以外にも、このプロジェクト特有のライブラリがいくつかインストールされていたので、これまで通り「必要のないライブラリは削除。必要なライブラリは最新にアップデート」を繰り返し、すべてアップデートされた状態になりました。

これからは、ついでに TypeScript 化と、まだ手付かずの Storybook をどうにかします。

15. TypeScript 化

まずは、TypeScript をインストールし、.js ファイルをすべて .ts.tsx に変更します。

あとは公式ドキュメント にもあるように、next などのコマンドを打てば勝手に tsconfig.json などの必要な設定ファイルを作ってくれます。本当 Next.js は気が利いてて好きになるわぁ。

先ほど babel の項で書いたエイリアスの設定も忘れずに。

tsconfig.json
{
  "compilerOptions": {
    ...
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
  "exclude": ["node_modules"]
}

あとは、Flow を削除したときにコメントアウトした分を、TypeScript の書き方で書き換えるだけですね。他にもエラーはもちろん出ましたが、そこまでハマることなく解消できました(細かいエラーは忘れた😂)

16. Storybook が動作するように修正

最後に Storybook がきちんと動作するように設定ファイルなどを修正します。正直小規模サイトなので Storybook はそこまで必要ではないので、削除しようかとも考えたのですが、今まで通り動作するを目標に頑張ってきたので、ここまできたらやるかという気持ちでした。

ほぼ作り直しみたいなものなので、設定ファイルは一度削除し、npx storybook@latest init で初期化しました。あとはこのプロジェクトに必要な設定を .storybook/main.ts.storybook/preview.ts に入れてあげるだけです。

main.ts には、静的ファイルディレクトリの設定と、import のパスエイリアスの設定を入れます。以下のような感じです。

.storybook/main.ts
const path = require('path');

const config: StorybookConfig = {
  stories: ["../src/components/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
  addons: [
    "@storybook/addon-links",
    "@storybook/addon-essentials",
    "@storybook/addon-onboarding",
    "@storybook/addon-interactions",
  ],
  framework: {
    name: "@storybook/nextjs",
    options: {},
  },
  staticDirs: ['../public'],
  docs: {
    autodocs: "tag",
  },
  webpackFinal: async config => {
    config.resolve = config.resolve || {};
    config.resolve.alias = {
      ...(config.resolve.alias || {}),
      '@': path.resolve(__dirname, '../src'),
    }
    return config;
  }
};
export default config;

preview.ts には styled-component や GlobalStyle を適用させるための設定を decorators オプションに入れます。

const preview: Preview = {
  parameters: {
    actions: { argTypesRegex: "^on[A-Z].*" },
    options: {
      storySort: {
        order: ["Atoms", "Molecules", "Organisms", "Templates", "Pages"],
      },
    },
  },
  decorators: [
    (Story: any) => (
      <ThemeProvider theme={theme}>
        <GlobalStyles />
        <Story />
      </ThemeProvider>
    ),
  ],
};

export default preview;

storySort の設定も Atomic Design を使っているのであればおすすめの設定です。以下の記事で解説しています。

これで、設定は完了なので、あとは、各 .stories.tsx ファイルを、新しい Storybook の書き方に変更するだけです。

StoriesOf を使う書き方からまったく書き方が変わっているので、具体的には説明しませんが、以下のように変更する感じですね。(もう書くの疲れた)

hoge.stories.tsx
import React from 'react';
import { storiesOf } from '@storybook/react';
import { text } from '@storybook/addon-knobs';

import Hoge from '.';

storiesOf('Atoms.Code', module)
  .add('default', () => (
    <Hoge text={text('text', 'lorem ipsum.')} />
  ));
// を以下に変更 
import React from "react";
import { StoryFn, Meta } from "@storybook/react";

import Hoge from ".";
type HogeProps = React.ComponentProps<typeof Code>;

export default {
  title: "Atoms/Hoge",
  component: Hoge,
} as Meta;

const Template: StoryFn<HogeProps> = (args) => <Hoge {...args} />;

export const Default = Template.bind({});
Default.args = {
  text: "lorem ipsum.",
};

全コンポーネント書き換えないといけないので、地味で大変な作業ですが、これを乗り越えて Storybook の最新版で立ち上げることができました。

17. 完了!

npm audit を実行すると...

うおおおおおおお!ついに vulnerabilities がついに 0 に!!🎉こうやって結果が見えると達成感ありますね。これからも 0 vulnerabilities をキープするぞー!

まとめ

とにかく作業もこの記事の執筆も疲れましたが、最終的にセキュリティリスクもなく、完全にモダンな開発環境が作れたことで達成感を味わえました。

最初にも書いた通り、大規模プロジェクトだとこうはいかないので、メジャーアップデートがあった時には面倒くさがらず早めにアップデートを心がけましょうね。そうすることでセキュリティアップデートにも柔軟に対応できるようになり、安心して開発をすることができます。

今回、アップデートの具体的な手順まで書きましたが、プロジェクトそれぞれでやることは違うと思います。とりあえず、この記事で言いたかったことはアップデートはいいぞです。

あと、今回の作業をやったことで Next.js とさらにお友達になれた気がします。また、こうやってアウトプットすることで、「なんかしらんけど上手くいった」的なものも、なぜ上手くいったのか深ぼってから記事を書く必要がありますので、こうやってアウトプットすることって本当大事ですね。

最初にアドバイスをいただいた ChatGPT にも報告しておきました。

長文読んでいただき、ありがとうございました☺️

Discussion