静的ホストでNext.jsの動的ルートをSPAにフォールバックする話

[Next.js Advent Calendar 2020](https://qiita.com/advent-calendar/2020/nextjs)の12日目の記事です。

# 2021/03/30 追記
Next.js 10.0.8から`_resolveHref`メソッドが`resolveDynamicRoute`メソッドに変更されています。また、この関数はexportされなくなったのでアプリ側から叩く方法がなくなってしまいました。しかし、`resolveDynamicRoute`メソッドで使用しているNextのメソッドは全てexportされているため、アプリ側に`resolveDynamicRoute`を定義すれば今まで通り実装可能です。

https://github.com/vercel/next.js/blob/33255b7f4ed6dd2608d6a6590d137353b6f19f67/packages/next/next-server/lib/router/router.ts#L341-L367

# はじめに
Next.jsは3の頃からルートを事前にレンダリングし、静的ファイルとしてエクスポートする機能があります。

SSRをした方がブログなど動的に追加されるコンテンツではパフォーマンスやOGPなどでメリットがあります。ただ、SSRをするインフラを構築して運用するのはなかなか大変なものです。

例えばログインしてからでないと動的ルートを使用しない場合などはOGPとか考えなくて良いので、静的ファイルとして吐き出し、初回ロード以外はSPAとして動作させてしまえばHTTPサーバーを立てるだけでアプリを公開できます。**楽！**

さらに、Next.js 9からファイルパスベースで動的ルートの定義ができるようになりました。

今までは`next.config.js`に静的に吐き出したいパスを定義する必要がありましたが、9からは`pages/path/[id].jsx`のようなファイルを作成することで`/path/1`といったルーティングを自動的に生成できるようになりました。**すごい！**

さあ満を持して、サーバーに静的ファイルをアップロードした！アクセスした！表示される！やったー！そしておもむろに`/path/1`をアドレスバーに打ち込んだ！

そこに表示されたのは…**404!**

そう、動的ルートは`/path/[id].html`として吐き出されていたのでした。

本記事ではこれを解決しましょう。

# 解決案
ここでは考えうる解決策を比較しましょう。

### サーバーでリライトする
この方法は既に @mottox2 さんの [Next.jsのDynamic Routing + Static HTML exportを組み合わせて使う](https://qiita.com/mottox2/items/29fbb55129f7c41f1ae6) で解説されている通り、動的ルートにアクセスがきた場合サーバー側で対応するファイルにリダイレクトするものです。

**Pros**

* サーバー設定だけでいいのでNext.js側の対応は不要

**Cons**

* ルートが増える度に設定の書き換えが必要

### 動的ルートはSPAでルーティングする

この方法はnuxt.jsなどが採用しています。( https://ja.nuxtjs.org/docs/2.x/configuration-glossary/configuration-generate/#fallback )

動的ルートにアクセスされた時、カスタム404ページでフォールバック用ページを返します。

フォールバック用ページはSPAでルーティング可能なルートであればそのページに移動し、不可能なルートであれば404をレンダーします。

**Pros**

* SPAにルーティングがあるのでルートが増えても自動対応できる

**Cons**

* サーバーは404を返しているけど、正常にページがレンダリングされるので？？ってなる

# で、どっちにするん？

タイトルの通り、「動的ルートはSPAでルーティングする」案でいきます。諸事情で頻繁にサーバー設定が弄れない環境だったんです。

**でも、そんな機能Next.jsにはないんですよ。**

# 実装
じゃあ、作りましょう。なお、対象とするNext.jsは10.0.3とします。

### 下調べ
この方法のミソは「フォールバック用ページはSPAでルーティング可能なルートであればそのページに移動し、不可能なルートであれば404をレンダーします。」ってところです。

そのためには、今のパスとマッチするルートは何かという情報をクライアントで取得する必要があります。Next.jsがクライアントでそのような処理をしてそうなところの目星を付けます。

Next.jsの9.5.3以降ではLinkコンポーネントのasを指定しなくても自動的に動的ルートのページを推定してくれるようになりました。
つまり、pathnameからルートを判断する仕組みがありそうです。

Linkコンポーネントを追っていくと[L221-229付近](https://github.com/vercel/next.js/blob/17c81cdce3621639a94a8adb1d78dbdd52205e61/packages/next/client/link.tsx#L221-L229)で`resolveHref`というメソッドを呼んでいますね。hrefからasを推定していそうな匂いがします。

あとは、これをページから呼べれば完璧です。

`resolveHref`は`packages/next/next-server/lib/router/router.ts`で定義されているのでみてみるとパッケージとしてexportされていないので叩けなさそうです。
しかし、[L1185-L1209](https://github.com/vercel/next.js/blob/42385f48dbd9bf6b95c0a01614ce422f3db5f81f/packages/next/next-server/lib/router/router.ts#L1185-L1209)付近にクライアントからも叩けるRouterクラスに`_resolveHref`メソッドが定義されているではありませんか。

では叩いてみましょう。この`_resolveHref`の第２引数にルート一覧を渡しますが幸運なことに[L700](https://github.com/vercel/next.js/blob/42385f48dbd9bf6b95c0a01614ce422f3db5f81f/packages/next/next-server/lib/router/router.ts#L700)付近で`pageLoader.getPageList`メソッドで取得できることがわかります。

```pages/path/[id].jsx
import Router from "next/router";
import { useEffect, useState } from "react";

export default function Path() {
  const [resolvedRoute, setResolvedRoute] = useState(undefined);
  const [asPath, setAsPath] = useState("");
  useEffect(() => {
    (async () => {
      const pages = await Router.router.pageLoader.getPageList();
      setResolvedRoute(
        Router.router._resolveHref({ pathname: Router.asPath }, pages)
      );
    })();
  }, []);

  return <div>{JSON.stringify(resolvedRoute)}</div>;
}
```

やったー！ルートが取得できました。

![スクリーンショット 2020-12-11 17.55.53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/50147/2a047895-b05e-b2bb-57dc-e7c3e510a9a9.png)

### フォールバックページの実装

無事マッチするルートが取得できたので、カスタムエラーページを作成して、リダイレクトの処理を書いていきましょう。

`_resolveHref`は存在しないルートでもとりあえず値を返すので、ルート一覧に存在する場合はリダイレクト、存在しない場合は404表示と切り分けています。

```pages/404.jsx
import Router from "next/router";
import { useEffect, useState } from "react";
import Error from "next/error";

export default function NotFoundPage() {
  const [isError, setIsError] = useState(false);

  useEffect(() => {
    (async () => {
      const pages = await Router.router.pageLoader.getPageList();
      const resolvedRoute = Router.router._resolveHref(
        { pathname: Router.asPath },
        pages
      );

      if (pages.includes(resolvedRoute.pathname)) {
        Router.replace(resolvedRoute.pathname, Router.asPath);
      } else {
        setIsError(true);
      }
    })();
  }, []);

  return <>{isError && <Error statusCode={404} />}</>;
};
```

### 動作テスト

ビルドします。

```bash
npm run build && ./node_modules/.bin/next export
```

nginxで生成したファイルをホスティングしてアクセスしてみます。404の時は`404/index.html`を返すようにします。

```default.conf
server {
    listen       80;

    location / {
        root   /var/www;
        index  index.html index.htm;
        try_files $uri $uri/index.html =404;
    }
    error_page   404 /404/index.html
}
```

まずは動的ルートにアクセスして、リロードしてみます。

![ezgif-7-c6628c821281.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/50147/1526de1d-211d-2241-e57d-51771aba5a7a.gif)

次に存在しない動的ルートにアクセスしてみます。

![スクリーンショット 2020-12-11 18.22.57.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/50147/f8d6d581-d150-d2d2-bf39-1be63d71cff9.png)

**完璧！**

# おわりに
Next.jsの非公開APIを叩いてたり、Next.jsのいいところを無視している構成な気がしますが、同じ悩みを抱える誰かの助けになると嬉しいです。

あとbasePathの対応はしてないです。

ソースコードはこちら→https://github.com/ainehanta/fallback-spa-nextjs