Next.jsの静的サイトで動的URLを処理するカスタムフック
はじめに
Next.js の静的サイト生成(Static Exports)で動的 URL(例: users/[userId]
)を扱う際に、クライアント側で実際の URL へリダイレクトを行うカスタムフックの実装について紹介します。
背景
Next.js で静的サイトを生成すると、動的 URL にアクセスした際に 404 ページが表示されることがあります。これは、たとえばusers/123
にアクセスした際に、users/123/index.html
が存在しないと判断されるためです。この問題に対処する方法は大きく分けて二つあります。一つはサーバー側でリクエストを正しい URL に変換すること、もう一つはクライアント側で 404 ページにて正しい URL にリダイレクトすることです。本記事では後者の方法に焦点を当てます。
カスタムフックの実装
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 ディレクトリからパス一覧を取得することができるため、この値を利用してアクセスしたパスに対応するページが存在するかを判定します
テストコード
単体テストということで少し細かく書いてます。
あくまで参考までに、また、上記の実装のカスタムフックの内容のイメージをつかむために読んでいただければと思います。
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 ページを表示することができます。
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)始めました!面白そうだと思った方はフォローいただけると嬉しいです!
Discussion