見出し画像

Nuxt.js v2製のブログサイトをNext.js(App Router)に移行してみた

この記事は、「株式会社メンバーズ Jamstack研究会主催 Advent Calendar 2023」の 8日目の記事です。


はじめに

この記事では、過去Next v2で作成していた個人ブログを、Next.jsに移行する手順を紹介します。

移行対象リポジトリは以下です。
https://github.com/taka1156/nuxt-blog

移行先のリポジトリは以下です。
https://github.com/taka1156/next-blog

以下、デプロイしたものです。
※ 改修中なのでNetlifyに手動アップロードしました。
(完全移行出来次第Firebaseに反映します。)
https://657f46c224919b1dc7d89ff9--taka1156-next-test.netlify.app/

Nuxt2で使っている技術の洗い出し

まず、Nuxt2で使っているモジュールとNextで使うモジュールの洗い出しをします。

調査の結果、基本的に、CSSとフレームワークに依存しない部分はそのままコードベースを移行できそうなことがわかりました。
また、Vueと同等の機能を提供するモジュールがあるため、特に移行に関して機能面で妥協する必要はなさそうです。

サイトマップはアナリティクス寄りの機能のため今回は対応しないこととしました。

Nextプロジェクトの作成

以下のコマンドを叩いてプロジェクトを作ります。

yarn create next-app
✔ What is your project named? … next-blog
✔ Would you like to use ESLint?YesWould you like to use Tailwind CSS?NoWould you like to use `src/` directory? … YesWould you like to use App Router? (recommended) … YesWould you like to customize the default import alias (@/*)? … Yes
✔ What import alias would you like configured? … @/*

上記設定の内容は以下になります。
TypeScript構成でCSSはとりあえずピュアなもの(CSS Modules)を使います。

ディレクトリ構成について

移行後のディレクトリ構成は以下のようにしました。
今回App Routerを利用しているのでpagesではなくappというディレクトリが生成されます。

src
 |_ app
 |_ components
 |_ constants
 |_ hooks
 |_ types
 |_ utils

以下に各ディレクトリの主な役割を説明します。
※ (追加)とあるのものは手動で追加したものになります

  • app
    ページのルーティングと対応したページ内容を記載するところです。

  • components (追加)
    大まかにAtomic Designのような区分けしてコンポーネントを管理しています。今回は作業対象外としましたが、単体テストやStorybookもこのディレクトリをメインに入れていきます。

  • constants (追加)
    固定のURLや固定のテキストを管理する箇所です。

  • hooks (追加)
    ナビゲーションの開閉管理やPC/SP判定、ページネーションなどの状態に近い処理を切り出して管理しています。
    今回、App Routerを利用する関係上、ページ内では基本的にHooksを使うことができないため、use clientという記載を追加して、CSR内で動作するようにしています。

  • types (追加)
    TSの型を管理しています。分類としては、axiosやブログ内の型とSSGに利用するパスなどの管理をしています。

  • utils (追加)
    どれにも該当しない雑多なファイルが分類されます。markedやdayjsの設定やSSGの取得処理を記載しています。増えてきたら、一部、pluginsディレクトリなどで切り出すのもありかもしれません。

移行作業の流れ・注意点

主な作業としては、以下のようになっています。

  1. Vueファイルをappとcomponents内に全て持ってくる。

  2. scoped cssで定義されている箇所を*.modules.cssというファイルに移動させる。

  3. *.tsxpage.tsxに拡張子を変えて<template>内をJSXにし、状態を持つ処理はhooksにする。

  4. CSSをモジュールとしてインポートする。

  5. classclassNameに書き換えて、className={styles.~}という記載に変更する。

基底コンポーネントは、親の変更を受け取るために、<slot />base-link--extendなどの記載がありますが、こちらは全てReactで使えるように変更します。

  • <slot />`は、ReactNode型を持つchildrenという引数に変更し、呼出しも<children />に変更

  • base-link--extendは、string型を持つextendClassというPropsに変更し親のもっているclassを適用できるように変更

propsの定義としては、元々VueのPropsに型定義のようなものを入れていたのでそちらを参考にしつつ定義していきます。

import { BaseLink } from '@/components/atoms/BaseLink/BaseLink';
import { BaseImg } from '@/components/atoms/BaseImg/BaseImg';
import styles from './ArticleBadge.module.css';

type ArticleBadge = {
  routePath: string;
  badge: CommonBadge;
  extendClass?: string;
};

const ArticleBadge = ({ routePath, badge, extendClass = '' }: ArticleBadge) => {
  return (
    <BaseLink routeTo={`/${routePath}/${badge.id}`}>
      <div className={`${styles.articleBadge} ${extendClass}`}>
        <span className={styles.articleBadgeText}>{badge.name}</span>
        <BaseImg
          size="sm"
          imgUrl={badge.img.url}
          imgAlt={`${badge.name}の画像`}
        />
      </div>
    </BaseLink>
  );
};

export { ArticleBadge };
<template>
  <base-link
    :route-to="{
      name: routePath,
      params: { id: badge.id }
    }"
  >
    <div class="article-badge article-badge--extend">
      <span class="article-badge__text">
        {{ badge.name }}
      </span>
      <base-img-lazy
        size="sm"
        :img-url="badge.img.url"
        :img-alt="`${badge.name}の画像`"
      />
    </div>
  </base-link>
</template>

<script>
import BaseLink from '../../atoms/BaseLink/BaseLink';
import BaseImgLazy from '../../atoms/BaseImgLazy/BaseImgLazy';

export default {
  name: 'ArticleBadge',
  components: {
    BaseLink,
    BaseImgLazy
  },
  props: {
    /**
     * 遷移先のルート情報
     */
    routePath: {
      type: String,
      required: true
    },
    /**
     * バッジの情報
     * (id、バッジ名、画像URL)
     */
    badge: {
      type: Object,
      required: true
    }
  }
};
</script>

<style scoped>
/* css reset */
p {
  padding: 0;
  margin: 0;
}

/* css reset */
.article-badge {
  display: flex;
  justify-content: center;
  padding: 2px;
  cursor: pointer;
  border-radius: 20px;
}

.article-badge__text {
  font-size: 12px;
  line-height: 30px;
}
</style>

Pages RouterとApp Router の違い

データ取得について

Pages Routerの時は、getStaticPropsでデータを取得し静的化する流れですが、App Routerの場合は、React Server Componentsを使う都合上 デフォルトがSSR、(キャッシュなどの設定によってはISR)としての動きをするようです。
また、その影響からか、getStaticProps(SSG)getServerSideProps(SSR)は使えないようになっています。
(仕組み的に必要性が低くくなりそうなので自然な様に感じます)

getStaticPathsとしての機能はgenerateStaticParamsとして存在しているため、こちらを利用してページのルーティングリストを生成し、
そのルーティングリストに対応するデータを生成します。

// 略 (article/[pageId]/page.tsx)

const range = (start: number, end: number) =>
  [...Array(end - start + 1)].map((_, i) => start + i);

export const generateStaticParams = async (): Promise<SSGArticlesPaths> => {
  const ARTICLES_PARAMS: MicroCMSParams = { fields: 'id' };

  const { totalCount } = await routesUtils.getSsgRouteParams(
    ARTICLE_URL,
    MICRO_CMS,
    ARTICLES_PARAMS
  );

  const pagePaths = range(1, Math.ceil(totalCount / POSTS_PER_PAGE)).map(
    (page) => ({ page: page.toString() })
  );

  return pagePaths;

    /* return [
   *    { page: "1" },
   *    { page: "2" },
   *    ...
   * ]
   */
};

const getStaticArticles = async ({ params }: ArticlesPath) => {
  // 記事取得処理
};

const Articles = async (props: ArticlesPath) => {
  // generateStaticParamsここで取得した処理がpropsに入ってくるイメージ

  const { articles, maxPage } = await getStaticArticles(props);

  return (
    <>
      <BaseHeading hLv="1" extendClass={styles.baseHeading1Articles}>
        Top
      </BaseHeading>
      <ArticleList articles={articles} maxPage={maxPage} routePath="articles" />
    </>
  );
};

export default Articles;

SEO・OGP(metadata)について

メタデータ生成は、従来のnext/headではなくexport generateMetadataという関数を作り動的に生成するか、export metadataとして静的な変数として出力させることで行うようです。
(参考: Next.js と TypeScript で、Metadata APIを理解する)

注意:
関数として生成したいときは、generateMetadata、定数時はmetadataと決まっているようなので使い分けましょう。自分は、metadataと仮で定義したまま、関数に変更して3時間ほど溶かしました(1敗)

また、今回は、試してませんがImage Generationで画像生成やapp 直下に特定の名前のOGP画像を置いてmeta設定を簡略化することもできるようです。

ルーティングについて

App Routerではnext/routerではなくnext/navigationが新しく追加されており、主にこちらに定義されている、useSearchParamsusePathnameuseParamsの3つのフックを利用するようです。
今回は、現在参照しているルーティング番号(ページネーション番号など)を取得する際にuseParamsを利用しています。

詰まった点

ページネーションありのページとなしのページの出し分け

nuxt2の時は example.com/articles/1(ルーティングありTop)とexample.com(ルーティングなしTop)でそれぞれ同じ内容を表示させたいというときに、Page Component定義は同じものを指しつつも手動でルーティングなしのTopページ定義を追加するようなことを行っていました。

export default {
    router: {
    trailingSlash: true,
    middleware: 'redirect',
    extendRoutes(routes, resolve) {
      routes.push({
        name: 'page-pageid',
        path: '/page/:pageid',
        component: resolve(__dirname, 'pages/index.vue')
      });
      routes.push({
        name: 'category-id-pageid',
        path: '/category/:id/:pageid',
        component: resolve(__dirname, 'pages/category/_id/index.vue')
      });
      routes.push({
        name: 'tag-id-pageid',
        path: '/tag/:id/:pageid',
        component: resolve(__dirname, 'pages/tag/_id/index.vue')
      });
    }
  },
}

NextでもgenerateStaticParams内で既存の記事を対象に取得した後に、手動でpageIdなしのルーティング定義を追加する処理を使えばできるかもしれませんが、本番運用では、ページ評価の分散を避けるためにcanonicalをつける可能性がある(Top/articles/1は内容的に同じ)ため、今回は、個別ページで生成するようにしてみました。
今後管理が煩わしいとのとこがあれば、一個にまとめて動的にmetadta内にcanonicalをつけるということも試してみたいと思います

トランジションについて

アニメーションについてVueに頼りっきりで、少し悩みましたが、Reactにもreact-transition-groupという近いことができるモジュールがあって助かりました。
フェードインなどページ遷移時のアニメーションも後々追加して行きたいと思います。

next/imageをSSGで利用する方法について

next/imageはnextのデフォルト機能として存在するコンポーネントで、遅延描画や画像最適化などの様々なことを行なってくれるコンポーネントです。
こちらをSSGでも使う予定で考えていましたが、そのまま利用するとbuild時などに以下のように出てしまいデフォルトでは使えません。

default loader is not compatible with `next export`.

対応としては、以下のように自前のAPIを実装して作成すると問題なく使用できるようになります。
※ おそらくSSGの時にNodeサーバーとして動かせない兼ね合い?

microCMSには画像最適化のためのAPIとしてimgix APIが用意されているのでこちらを使います。

  const microCMSLoader = ({ src }: ImageLoaderProps) => {
    return `${src}?auto=format`
  }
'use client';
import Image from 'next/image';
import { microCMSLoader } from '@/utils/imgix';
import styles from './BaseImg.module.css';

type BaseImg = {
  imgUrl: string;
  imgAlt: string;
  size: 'sm' | 'lg' | 'free';
  img?: {
    height: number;
    width: number;
  };
  extendClass?: string;
};

const sizeList = {
  sm: {
    size: 20,
  },
  lg: {
    size: 50,
  },
};

const BaseImg = ({ imgUrl, imgAlt, size, extendClass = '' }: BaseImg) => {
  if (size !== 'free') {
    return (
      <Image
        loader={microCMSLoader}
        src={imgUrl}
        alt={imgAlt}
        height={sizeList[size].size}
        width={sizeList[size].size}
        className={`${styles.baseImg} ${styles[size]} ${extendClass}`}
      />
    );
  } else {
    return (
      <img
        src={imgUrl}
        alt={imgAlt}
        className={`${styles.baseImg} ${styles[size]} ${extendClass}`}
      />
    );
  }
};

export { BaseImg };

free があるのは、GitHub Readme Statsの埋め込みやGithub Chart APIを利用したコントリビューショングラフを貼り付ける際にCSSで細かく指定したいことがあったため追加しています。

残った課題

以下今回の残タスクとして残ったものです。
少しずつ改善する予定なので機会があれば、閲覧などよろしくお願いします。

  • sitemap

  • test、storybookの更新

  • axios => microCMS公式のモジュールを利用する。

まとめ

Next.jsについて久しぶりに触れてみましたが、デフォルトの機能でもできることがかなり多く驚きました。

また、普段Vue系の技術を利用していたことが多い自分でも、同等の機能が用意されているので安心して使用することができました。

今後は、Vue/NuxtだけでなくApp RouterやNextの機能を積極的に触れて行きフロントエンドの知見を広げて行きたいと思います。

この記事が気に入ったらサポートをしてみませんか?