Create React AppからNext.jsへの移行事例紹介

16 min read読了の目安(約14700字

はじめに

この記事は、筆者がOOPartsというプロダクトにおいて、Reactのアプリを 「Create React App」 から 「Next.js」 に置き換えた事例を記す内容となっています。

http://oo.parts/

これまで 「0からのNext.jsアプリケーションの作成」 文脈における記事は多くありましたが、「Create React App」から「Next.js」という、 同じReact環境における移行記事 はそこまで多くなかったと認識しています。

ある程度育ちきっているプロダクトであれば、フレームワークごと移行することは中々困難になると思っていますし、それを成し遂げることはとてもチャレンジングなことです。その結果、事例としての大規模移行事例は中々存在しませんし、稀有なことだと思っています。

本記事におけるOOPartsのNext.js移行に関する知見は、今後大きな移行する人たちの参考になれば良いと思っています。必ずしも他のプロダクトに転用できるものではないと考えているものの、実例の1つとして捉えていただければ幸いです。

また、公式による移行方法も合わせてご覧になっていただければと思います。

https://nextjs.org/docs/migrating/from-create-react-app

移行した背景

「Create React App」から「Next.js」に置き換えた背景として大きく以下があります。

  • SSRやSSGを利用することで、OGP生成やSEOなどをより簡単に対策したい。

  • Next.jsのドキュメントを見れば誰でも開発できるような状態にしたい。

  • 「react-app-rewrited」を当初から利用しているのだが、「Create React App」のレールから外れてしまう。また、サポートから外れてしまうため利用したくない。

  • 開発時における構文エラーや高速リフレッシュなど、リッチな機能を利用したい。

  • コード分割や最適化をある程度自動で行いたい。

  • (直接的な理由ではないが)ビルドに時間がかかってしまっているため、Next.jsで高速ビルドをしたい。

また、「Create React App」が悪いわけではないのですが、少人数でスピード感をもって開発していくには「Next.js」のようなフレームワークにある程度乗っかって開発する方が、ソフトウェアのリリースライフサイクルの観点から良いと判断したためです。

移行時における「Create React App」と「Next.js」の違い

まず「Create React App」と「Next.js」の違いを知るところからスタートとなります。両者ともReact製なので、ある程度はソースファイルを使い回すことが可能ですが、それでもなかなか移行には手間取ります。

実際、筆者が直面した「Create React App」と「Next.js」の大きな差分は主に以下の3つだと感じています。

  • ルーティングライブラリがCreate React Appでは自分で選定できるが、Next.jsは next/router が受け持つ
    • その都合上、「Next.js」ではFile-system Routingが前提となる
  • レンダリングがCreate React Appではクライアント前提で行われるが、Next.jsはSSRやSSGが前提
    • その都合上、「Next.js」ではサーバーを用意するケースもある
  • 画像やSVGなどにおけるアセットの扱い
    • next/image に載っかるかどうかでも変わってくる

また、「Create React App」はReact製のツールですが、「Next.js」はReact製のフレームワークですので、根本的に提供されている機能の多さや、カスタマイズ性が異なっています。ですが、OOPartsで行った実際の移行時の差分としてクリティカルでない部分は今回省略させていただきます。

「Next.js」移行への戦略

上記の違いを踏まえ、筆者はOOPartsをNext.jsに移行する際、以下の戦略を取りました。

フェーズを3段階に分け、段階的にNext.jsのフル機能を使っていく

なぜフェーズを分けるのかというと、「Create React App」がクライアントサイドレンダリングが前提なのですが、「Next.js」ではSSRが前提になっているという点です。

この影響がとても大きく、例えば以下のようにブラウザで利用可能なAPI(WebAPI)を呼ぼうとすると「Next.js」ではエラーになってしまいます。

pages/index.tsx
function Index() {
  const pathname = location.pathname;

  return (
    <div>{pathname}</div>
  );
}

export default Index;

このエラーをひたすら潰していくのがNext.js移行におけるはじめの関門と筆者は思っています。なので、はじめにNext.jsでアプリがコンパイル可能となり、とりあえず動くことを目標とします。そしてその次のフェーズでNext.jsの機能をフルに使っていきます。

なお、OOPartsはまだ1段階目であり、2段階目は今後実装していく予定です。

それでは3つの段階を詳しく説明していきます。

1段階目「CSRベースでNext.jsを動かす」

なので、1段階目にやるべきこととしては、「Next.js」を利用しつつもクライアントサイドレンダリング的な実装をしていきます。

具体的には_app.tsx に以下のようなコードを書くイメージです。

pages/_app.tsx
function MyApp({ Component, pageProps }) {
  const [mounted, setMounted] = useState<boolean>(false);
  useEffect(() => {
    setMounted(true);
  }, []);

  return mounted ? <Component {...pageProps} /> : <div/>;
}

export default MyApp;

この記述によりNext.jsでありながら、CSR的な挙動をします。そして先程のhostname に関しても、大きな修正を加えることがなく移行することが可能になります。

しかし、この実装は諸刃の剣です。これでは Next.jsの機能を全く使えていないので、次以降のフェーズで徐々にNext.jsの機能を利用できるようにしていきます。

また、ルーティングにも大きく手を入れます。OOPartsではReact Routerをこれまで利用していたのですが、そこを全て next/router に移行していきます。今回はアニメーション部分の移行はプロダクトに強く紐づいてしまうため省略いたしますが、それぞれのアプリケーションに沿った緻密な移行作業が必要となります。

また、ルーティングの移行に関しては公式でも取り扱っているので、ぜひ参考にしてみてはいかがでしょうか。

https://nextjs.org/docs/migrating/from-react-router

2段階目「SSRに実装を寄せる」

2段階目では実際にSSRをできるように実装を変更していきます。

厳密に言うと3段階目のSSGを実現するために一旦SSRでコンパイルが通るように修正していきます。その都合上、ここでは getServerSideProps を用いたSSRの紹介は今回省略とさせていただきます。

https://nextjs.org/docs/basic-features/data-fetching#getserversideprops-server-side-rendering

実際の手順としては 「WebAPI部分はクライアント側で、それ以外はSSRになる」 ように実装を変更していきます。

例えば、公式に従うと以下のようなコードを用いることで、SSRでエラーが起こらなくなりクライアント側でのみ動作します。実際に1段階目で行っている手法を適宜適用していく形です。

import { useEffect } from 'react';

useEffect(() => {
  // You now have access to `window`
}, []);

しかしながら、使用しているライブラリによってはimport するだけでSSRに失敗してしまう可能性があります。その場合は下記のようにして、回避することが可能です。

let yourLibrary;
if (typeof window !== 'undefined') {
  yourLibrary = require('your-library');
}

このようにして、1段階目で _app.tsx レベルでCSRしていた部分を1つずつ丁寧に剥がしていくのが第2フェーズとなります。

しかし、2段階目では実際にSSRをするためのサーバーが必要となってきます。3段階目ではSSGを利用することにより、1段階目と同じく静的ホスティングが可能な形へと移行していきます。

3段階目「SSGへと移行する」

そして、3段階目はSSGに対応していきます。

厳密にいうと2段階目である程度はSSGは可能となりますが、ここでいうSSG移行はOOPartsでいうと /title/:id のようなページを あらかじめ全てSSGしておく という意味で考えます。

SSGを利用することにより静的ホスティングが可能となり、高速なページ表示を実現できます。特にOOPartsの場合は現状ホスティングをFirebase経由で行っているため、 next export 前提の実装に寄せていきます。

実際には下記を参考にし、 getStaticPropsgetStaticPaths を用いてSSGに対応していきます。

https://nextjs.org/docs/basic-features/data-fetching#getstaticprops-static-generation

https://nextjs.org/docs/basic-features/data-fetching#getstaticpaths-static-generation

Before

pages/title/id.tsx
function Id() {
  const router = useRouter();
  const { id } = router.query;
  const [game, setGame] = useState();

  useEffect(() => {
    fetch(`https://.../game/${id}`)
      .then((res) => res.json())
      .then((game) => setGame(game));
  }, []);

  return <div>{game?.name}</div>;
}
export default Id;

After

pages/title/id.tsx
function Id({ game }) {
  return <div>{game.name}</div>;
}

export default Id;

export async function getStaticProps({ params }) {
  const res = await fetch(`https://.../game/${params.id}`);
  const game = await res.json();

  return {
    props: {
      game,
    },
  };
}

export async function getStaticPaths() {
  const res = await fetch(`https://.../games`);
  const games = await res.json();
  const gameIds = games.map(game => game.id);

  return { paths: gameIds, fallback: false };
}

なお、 fallbacktrue としてしまうと、Firebase HostingなどのCDNにデプロイする場合、フォールバック後に処理するサーバーが存在しないために next export ができないので注意しましょう。

デプロイ先をVercelにしている場合はこの限りではございません。

https://nextjs.org/docs/basic-features/data-fetching#fallback-pages

また、更新頻度が多い場合は 「Incremental Static Regeneration」 などの手も考えられますので、サービスの性質によって戦略を考えると良いでしょう。

https://nextjs.org/docs/basic-features/data-fetching#incremental-static-regeneration

ちなみに、OOPartsは認証にFirebase Authenticationを用いていますが、認証が必要な部分のレンダリングに関しては全てCSRに任せています。
ここをSSRないしSSG対応していくとなると、より設計やセキュリティ観点が問われる部分になりますが、本記事では省略させていただきます。

OOPartsにおける実際の移行手順

ここからはOOPartsで行った第1段階目の移行手順の一部をより詳しく紹介していきます。なお、テストに関しては今回省略します。

ファイル構成を整える

今回のように大きくフレームワークごと変更するとなると、まず初めに何を行えば良いのかわからなくなってしまうものです。

なので一旦動くかどうかは置いておき、ファイルの構成だけを確定させてしまいました。

OOPartsのファイル構成はもともと src ディレクトリに全てのソースファイルがいる構成でしたが、今回の Next.js 移行では src ディレクトリを廃止し、 npx create-next-app ooparts からスタートしています。

なお、 src ディレクトリを利用する方法も公式から案内されていますので、確認しておくと良いでしょう。

https://nextjs.org/docs/advanced-features/src-directory

型を合わせてコンパイルを通す

ここでも一旦動くかどうかは置いておきます。OOPartsはTypeScriptで書かれているので、とりあえずコンパイルが通るところまでひたすら修正していきます。

テストコードがしっかりと書かれているプロダクトなのであれば、テストが通るかどうかでも判断できることでしょう。

また、コンパイルを通すために先ほど紹介した _app.tsx の実装はここで使用します。OOPartsでは以下のコードを仕込んだ上で、コンパイルを通しにいきました。

pages/_app.tsx
function MyApp({ Component, pageProps }) {
  const [mounted, setMounted] = useState<boolean>(false);
  useEffect(() => {
    setMounted(true);
  }, []);

  return mounted ? <Component {...pageProps} /> : <div/>;
}

export default MyApp;

また、 import の段階でSSR不可能なコードに関しても先ほど紹介した方法で回避していきます。

let yourLibrary;
if (typeof window !== 'undefined') {
  yourLibrary = require('your-library');
}

ルーティングの移行

ここから実際にNext.jsで動くように修正していきます。Next.jsに移行する際、一番大きく変更しなければいけないのはルーティングライブラリだと思っています。

OOPartsの場合は一旦認証処理を取り除いて pages フォルダにファイルを配置していきました。特にダイナミックルーティングに関しては少しテクニックがいることかとは思いますが、一つ一つ丁寧に置き換えていきます。例えば404ページは以下のようになっています。

pages/404.tsx
function NotFound() {
  return (
    <div>
      <span>Not Found 404</span>
    </div>
  );
}

export default NotFound;

OOPartsの場合、 title/:id に関しては元々アニメーションガッツリのReact Routerを利用していたため、next/router に移行するには少し手間取りました。

title/:id のようなダイナミックルーティングにする場合、一旦SSR/SSGを無視して動かすことがゴールになるため、下記のような実装になりました。

pages/title/id.tsx
function Id() {
  const router = useRouter();
  const { id } = router.query;

  return <Title {...{ id }} />;
}

export default Id;

ルーティング周りの移行は実際に next dev コマンドで随時コンパイルが通るかを確認しつつ、ページが表示されるかを確認しましょう。

この段階では画像崩れやスタイル崩れが起こると思いますが、とりあえず何かしら表示されることをゴールとしましょう。

リンクの移行

aタグなどのリンクは next/link に移行していきます。

https://nextjs.org/docs/api-reference/next/link

OOPartsの場合、下記のようにリンクを移行していきます。( to は同一アプリ内でのルーティング時に利用)

Before

import { Link as RouterLink } from 'react-router-dom';

export const Link = ({ silent, href, to, children, ...other }) => {
  if (to) { // to: H.LocationDescriptor;
    return (
      <RouterLink to={to} {...other}>
        {children}
      </RouterLink>
    );
  } else {
    return (
      <a href={href} {...other}>
        {children}
      </a>
    );
  }
};

After

import NextLink from 'next/link';

export const Link = ({ silent, href, to, children, ...other }) => {
  if (to) { // to: H.LocationDescriptor;
    return (
      <NextLink href={to} scroll={true}>
        <a href={to.toString()} {...other}>
          {children}
        </a>
      </NextLink>
    );
  } else {
    return (
      <a href={href} {...other}>
        {children}
      </a>
    );
  }
};

元々Linkが抽象化されているのであれば、ここは大きな問題とならないはずです。

アセットの移行

ここでいうアセットの対象はCSSや画像になります。

CSSに関してはOOPartsの場合、元々 emotion というライブラリを使用していましたが、公式にもサンプルがあるため移行しやすかったです。

https://github.com/vercel/next.js/tree/master/examples/with-emotion

また、画像に関しては「Next.js 10」から新たに導入された next/image に置き換えていきました。

https://nextjs.org/docs/api-reference/next/image

OOPartsではimgixを利用しているため、実際には下記のようなコンフィグを用います。

next.config.js
module.exports = {
  images: {
    loader: 'imgix',
    path: 'https://example.com/myaccount/',
  },
}

https://nextjs.org/docs/basic-features/image-optimization#loader

また、実際のコードは下記のように移行していきました。

Before

import styled from '@emotion/styled';
import LogoImage from '~/assets/images/logo.png';

const StyledImg = styled.img();
const Img = props => <StyledImg {...props} />;

<Img src={LogoImage} style={{ width: '100px' }} />

After

import Image from 'next/image';

<Image src="/images/logo.png" alt="OOParts" width={150} height={32} />

特徴的なのは widthheight を指定する点です。これにより Next.js が最適化を行ってくれます。

また、OOPartsでは今回の移行により、全ての画像を imgix に移しておきました。

移行時に起きた事件(PWAの移行に失敗)

もちろんNext.js移行に関して全てが順調に進んだわけではございません。一番大きく踏み抜いてしまったミスについて、最後に触れていきます。

OOPartsは元々 workbox-webpack-plugin を用いてPWAを実現していました。

https://developers.google.com/web/tools/workbox/modules/workbox-webpack-plugin

そして今回Next.jsに移行する際は next-pwa を用いて、ServiceWorkerの実装を変更しました。どちらもWorkboxを利用しているので、使用感に関しては特に違いはないのですが、キャッシュ戦略を変更しています。

https://www.npmjs.com/package/next-pwa

SafariにService Workerが実装されて数年が経ちましたが、OOPartsのアルファ版がリリースされた時はキャッシュ周りがとても不安定でした。それこそ全てタブを閉じないとキャッシュの時間を0にしていてもPWAの内容が新しくならないなど、とても苦しめられていました。(今は改善されています)

その結果Next.js移行前のOOPartsでは、PWAとは言っているものの、キャッシュ戦略としてはアセットファイル以外は全て NetworkOnly でした。

当時Safariのキャッシュ挙動に苦しめられていたため、筆者は何を思ったのか index.html すらキャッシュの対象から外せばとりあえず解消する。以下のようなコードを書けばOKや」 と思っていました。

以下のコードは真似しないでください

config-overrides.js
const { override, ... } = require('customize-cra');
const { GenerateSW } = require('workbox-webpack-plugin');
...
module.exports = {
  webpack: (config, env) => {
    const customized = override(...)(config, env);
    customized.plugins.forEach(plugin => {
      if (plugin instanceof GenerateSW) {
        plugin.config.exclude = ['index.html'];
      }
      ...
    }
    return customized;
  },
};

この結果、生成される service-worker.js の一部は以下のようになります。

service-worker.js
importScripts("https://storage.googleapis.com/workbox-cdn/releases/.../workbox-sw.js");
importScripts("/precache-manifest.xxx.js");

...

workbox.routing.registerNavigationRoute(workbox.precaching.getCacheKeyForURL("/index.html"), {
  blacklist: [/^\/_/,/\/[^\/]+\.[^\/]+$/],
});

そして plugin.config.exclude = ['index.html']; の記述により、 /precache-manifest.xxx.js ファイルにはもちろん index.html は含まれません。これが潜在バグになってしまい、今回のNext.js移行における大きな障壁となりました。

まず現象として何が起きたのか。それは単純で以前に一度でもOOPartsにアクセスしたことがある人を対象に 「リリース後トップページが404になってしまう」 という現象です。この現象はタブを閉じたり、スーパーリロードをするまで解消されません。何故そのような現象が起きてしまったのか。

それは、プリキャッシュで index.html を除外したことにより、 workbox.precaching.getCacheKeyForURL("/index.html")undefined を返してしまうことに起因しています。これによりService Worker状況下では / にアクセスすると /undefined のキャッシュを返します。これでは本来404ページを返すはずです。では、そもそもなぜ今までのOOPartsは無事に動いていたのでしょうか?それはFirebase Hostingに秘密が隠されています。

元々のOOPartsは先ほどから説明している通り「Create React App」で実装されているSPAです。その結果、 firebase.json には以下のような記述をしています。

firebase.json
{
  ...,
  "hosting": {
    "public": "build",
    "ignore": ["firebase.json", "**/.*", "**/node_modules/**"],
    "headers": [
      ...,
      {
        "source": "**",
        "headers": [
          {
            "key": "Cache-Control",
            "value": "no-cache"
          }
        ]
      }
    ],
    "rewrites": [
      ...,
      {
        "source": "**",
        "destination": "/index.html"
      }
    ]
  }
}

重要なのは rewrites の部分ですが、SPAである都合上、全てのパスを index.html に書き換えています。

その結果、workbox.precaching.getCacheKeyForURL("/index.html")undefined を返してしまい、Service Workerの問い合わせが404になってしまったとしても、404ページは index.htmlrewrite されているので、バグってはいるもののユーザーからの見た目は特に問題ありませんでした。(ユーザーの見た目以外は問題大ありです)

undefinedでもアクセス可能状況

そしてNext.js移行時にはこの rewrites における index.html への書き換えはなくなるので、当然jsonから削除します。(削除しなければ正しくルーティングできません)

そしてService Wokerの特徴としてもう一つ考えなければいけないのが、ライフサイクルです。詳しくは下記をご覧ください。

https://developers.google.com/web/fundamentals/primers/service-workers/lifecycle?hl=ja

Service Workerのデフォルトの挙動では、インストールの前にWaiting(待機)状態になり、タブを閉じるなどをして初めてインストールされます。

その結果、Next.js移行時の挙動としては 「ファイル構成と firebase.json は Next.jsベース」 になるが、 「待機状態でなくなるまでService WorkerはCreate React Appの状態」 になります。

その結果 rewritesindex.html に書き変わらないので、レスポンスは以下のようになります。

undefinedで404になっている状況

これではトップページにアクセスした場合、404ページが表示されてしまいます。やばいです。

筆者がリリースしたとき、このバグに気付き暫定対応で以下のような対応をすぐ取りました。

ServiceWorker.ts
import { skipWaiting, clientsClaim } from 'workbox-core';

self.skipWaiting();
clientsClaim();

https://developers.google.com/web/tools/workbox/modules/workbox-core#skip_waiting_and_clients_claim

上記のコードを用いてWaiting状態をスキップさせ、強制的に新しいService Workerへと切り替えさせました。本来であれば特定の理由がない限り推奨されないメソッドですが、今回ばかりは特殊ケースであったためこのような対応をさせていただいてます。後方互換及び上記のコードを消すタイミングは現在検討している最中です。

これにより、無事OOPartsのNext.js移行は完了しています。

おわりに

細かい部分は省きましたが、以上が現状OOPartsで主に取り組んでいるNext.js移行手順になります。これに加え、アニメーションの再現やスクロール位置の保存などの移行トピックはありますが、またの機会にご紹介できればと思います。

そして、OOPartsのNext.js移行はまだ1段階目です。これからが勝負だと思っています。がんばります。