💡

Next.jsの静的サイトで動的URLを処理するカスタムフック

に公開

はじめに

Next.js の静的サイト生成(Static Exports)で動的 URL(例: users/[userId])を扱う際に、クライアント側で実際の URL へリダイレクトを行うカスタムフックの実装について紹介します。

背景

Next.js で静的サイトを生成すると、動的 URL にアクセスした際に 404 ページが表示されることがあります。これは、たとえばusers/123にアクセスした際に、users/123/index.htmlが存在しないと判断されるためです。この問題に対処する方法は大きく分けて二つあります。一つはサーバー側でリクエストを正しい URL に変換すること、もう一つはクライアント側で 404 ページにて正しい URL にリダイレクトすることです。本記事では後者の方法に焦点を当てます。

カスタムフックの実装

src/pages/404/useCheckNotFoundAndRedirect.ts
import { useEffect } from 'react';

import Router, { useRouter } from 'next/router';

/**
 * Next.jsの動的ルーティングを考慮したリダイレクト処理を行うカスタムフック
 * @param onCallback マッチするページがなかった場合に呼ばれるコールバック関数
 */
export const useCheckNotFoundAndRedirect = (onCallback: () => void) => {
  const router = useRouter();

  useEffect(() => {
    const path = router.asPath;

    /**
     * アクセスしたパスのパターンで、pagesにマッチするページがあるか判定する関数
     */
    const hasPagePath = async () => {
      const pages = (await Router.router?.pageLoader.getPageList()) || []; // 実装中のpagesを取得

      /**
       * pageとパスのパターンがマッチするか判定する関数
       */
      const isMatchPagePath = (
        pagePattern: string,
        currentPath: string
      ): boolean => {
        const regexPattern =
          pagePattern.replace(/\[\w+\]/g, '([^/]+)') + '\\/?';
        const regex = new RegExp(`^${regexPattern}$`);
        const match = currentPath.match(regex);

        if (match) return true;

        return false;
      };

      // pagesの中で現在のパスにマッチするページがあればtrueを返す
      for (const page of pages) {
        const isMatch = isMatchPagePath(page, path);
        if (isMatch) {
          return true;
        }
      }

      // マッチするページがなければfalseを返す
      return false;
    };

    hasPagePath().then((isMatch) => {
      if (isMatch) {
        // pagesにページパスがあればそのページにリダイレクト
        router.push(path);
        return;
      }

      // マッチするページがなければcallbackを実行(404ページを表示)
      onCallback();
    });
  }, [onCallback, router]);
};

コメント力が問われそうなコードになってしまいましたが 😅
ChatGPT にコードを書いてもらって、僕の方でリファクタリングしたコードです。
(僕は正規表現苦手なので、そのあたりの実装をしてくれるのはありがたいですね。)

実装のポイント

await Router.router?.pageLoader.getPageList()で pages ディレクトリからパス一覧を取得することができるため、この値を利用してアクセスしたパスに対応するページが存在するかを判定します

テストコード

単体テストということで少し細かく書いてます。
あくまで参考までに、また、上記の実装のカスタムフックの内容のイメージをつかむために読んでいただければと思います。

src/pages/404/useCheckNotFoundAndRedirect.test.ts
import PageLoader from 'next/dist/client/page-loader';
import router, { Router as TRouter } from 'next/router';
import Router from 'next/router';

import { renderHook, waitFor } from '@testing-library/react';

import { useCheckNotFoundAndRedirect } from '@/pages/404/useCheckNotFoundAndRedirect';

jest.mock('next/router', () => jest.requireActual('next-router-mock'));

const pages = [
  '/',
  '/group/[groupId]',
  '/groups/[groupId]/tasks/[taskId]',
  '/notes',
];

/**
 * テスト用にセットアップする関数
 */
const setupRouter = (path: string) => {
  router.asPath = path;
  Router.router = {
    pageLoader: {
      getPageList: () => pages,
    } as PageLoader,
  } as TRouter;
  const callback = jest.fn();
  const routerPushSpy = jest.spyOn(router, 'push');
  return {
    callback,
    routerPushSpy,
  };
};

beforeEach(() => {
  jest.clearAllMocks();
});

describe('useCheckNotFoundAndRedirect', () => {
  describe('存在するパスにアクセスしていた場合、callbackが発火されずにそのページへリダイレクトすること', () => {
    describe('静的URL', () => {
      it('"/notes"にアクセスした場合、"/notes"にリダイレクトされること', async () => {
        // arrange
        const { routerPushSpy, callback } = setupRouter('/notes/');

        // act(カスタムフックの呼び出し)
        renderHook(() => useCheckNotFoundAndRedirect(callback), {});

        // assertion
        await waitFor(() => {
          expect(callback).not.toHaveBeenCalled();
          expect(routerPushSpy).toHaveBeenCalledWith('/notes');
        });
      });
    });

    describe('動的URL', () => {
      it.each([
        [
          'URLパラメータが単一: "/group/12345"にアクセスした場合、"/group/12345"にリダイレクトされること',
          '/group/12345',
          '/group/12345',
        ],
        [
          'URLパラメータが複数: "/groups/12345/tasks/67890"にアクセスした場合、"/groups/12345/tasks/67890Z"にリダイレクトされること',
          '/groups/12345/tasks/67890',
          '/groups/12345/tasks/67890',
        ],
      ])('%s', async (_caseName, currentPath, redirectedTo) => {
        // arrange
        const { routerPushSpy, callback } = setupRouter(currentPath);

        // act(カスタムフックの呼び出し)
        renderHook(() => useCheckNotFoundAndRedirect(callback), {});

        // assertion
        await waitFor(() => {
          expect(callback).not.toHaveBeenCalled();
          expect(routerPushSpy).toHaveBeenCalledWith(redirectedTo);
        });
      });
    });

    describe('末尾にスラッシュがある場合、スラッシュなしのURLに変換されること', () => {
      it('「/notes/」にアクセスした場合、「/notes」にリダイレクトされること', async () => {
        // arrange
        const { routerPushSpy, callback } = setupRouter('/notes/');

        // act(カスタムフックの呼び出し)
        renderHook(() => useCheckNotFoundAndRedirect(callback), {});

        // assertion
        await waitFor(() => {
          expect(callback).not.toHaveBeenCalled();
          expect(routerPushSpy).toHaveBeenCalledWith('/notes');
        });
      });
    });
  });

  it('存在しないパスにアクセスしていた場合、callbackが発火されること', async () => {
    // arrange
    const { routerPushSpy, callback } = setupRouter('/not-found');

    // act(カスタムフックの呼び出し)
    renderHook(() => useCheckNotFoundAndRedirect(callback), {});

    // assertion
    await waitFor(() => {
      expect(callback).toHaveBeenCalled();
      expect(routerPushSpy).not.toHaveBeenCalled();
    });
  });
});

使い方

以下のような 404 ページコンポーネントを作成することで、アクセスしたパスに対応するページが存在する場合はそのページにリダイレクトし、存在しない場合は 404 ページを表示することができます。

src/pages/404/index.page.tsx
import { useState } from 'react';

import { NextPage } from 'next';

import { useCheckNotFoundAndRedirect } from '@/pages/404/useCheckNotFoundAndRedirect';

const Custom404: NextPage = () => {
  const [isNotFound, setIsNotFound] = useState(false);
  useCheckNotFoundAndRedirect(() => setIsNotFound(true));

  if (!isNotFound) return null;

  return <>404ページです</>;
};

export default Custom404;

404 ページでは以下のような state を通じて、404 ページを表示するかどうかを判定しています。

const [isNotFound, setIsNotFound] = useState(false);

該当するページがなかった時に発火させる callback 関数として、以下のように state を更新する関数を渡しています。

useCheckNotFoundAndRedirect(() => setIsNotFound(true));

実装の注意点:

ブラウザで javascript が実行できないとこの処理は機能しないため、また、アクセス時に対応する html が表示されるのではなく最初は 404 の内容が実行され SEO に不利かもしれないので、本来はサーバーやインフラ側で対応するのが理想です。
それでも、この方法は簡単に実装でき、応急処置や妥協案としては十分な効果があります。

Web アプリのログインユーザーが見れるようなページであれば SEO は関係ないので、この方法で良いかと思います。

まとめ

このカスタムフックは、Next.js の静的サイトで動的 URL を扱う際の簡単な解決策を提供します。実装の簡便さを考慮すると、多くのプロジェクトで有用な方法と言えるでしょう。

X(Twitter)始めました!面白そうだと思った方はフォローいただけると嬉しいです!

https://twitter.com/t74WOJ5WP960237

Discussion