kurosame’s diary

フロントエンド中心です

静的サイトの Next.js を SPA でルーティングさせる

はじめに

この記事の内容は、以下のサイトと同じことをやってます
この記事は自分用のメモです

https://colinhacks.com/essays/building-a-spa-with-nextjs
https://blog.hey3.dev/posts/nextjs-spa

概要

Next.js アプリを静的ファイルにビルドし、S3 にデプロイ、CloudFront で公開していた
しかし、動的ルーティングを使うことになり、これ自体は Next.js がサポートしているので問題ないが、/task/[id].htmlに対して URL から直接アクセスが来た場合に 403 エラーとなってしまう
単純にルートの/index.htmlに飛ばすだけで良いなら、CloudFront のカスタムエラーページの設定で対応できるが、今回はシステムの仕様上、それはできず、/task/[id].htmlの内容を表示させる必要がある

今回やりたいことは以下である

  • システムは静的サイトホスティングする
    • サーバー上でシステムは動かさない
  • ルートの JS でルーティングを実装する
    • Next.js のルーティングを利用しない
  • URL 直接入力の場合、必ずルートの JS を経由させ、ルーティングを通るようにする

Next.js はファイルシステムに沿ったルーティングを持つ(pages ディレクトリにファイルを配置すると自動でルーティングされる)
しかし、サーバー上で動いていない Next.js では動的ルーティング + URL 直接入力に対応できない
こちらはreact-routerで対応する

そうなると、create-react-appで良くて、Next.js 使わなくてもいいんじゃないって感じだけど、バンドルの分割機能やページ単位の SSR など Next.js 機能の恩恵もあるのと、途中で Next.js を使うのやめるのちょっとしんどいので、Next.js の利用を継続する

Nuxt.js であれば、mode: spassr: falseで対応できるのかもしれない

react-routerの実装

npm i react-router-dom

ルートの JS でルーティングを実装する

// pages/index.tsx
import { BrowserRouter, Route, Switch } from 'react-router-dom'

const App: React.VFC = () => (
  <BrowserRouter>
    <Switch>
      <Route path="/sign-in" component={SignIn} exact />
      <Route path="/" component={Home} exact />
      <Route path="/task/:id" component={Task} exact />
    </Switch>
  </BrowserRouter>
)

エラーの対応

ルーティング実装後、サーバー上で動かすと以下のエラーが出る

Error: Invariant failed: Browser history needs a DOM

また、静的ビルドの場合は、以下のエラーになる

Error occurred prerendering page "/". Read more: https://err.sh/next.js/prerender-error

これはreact-routerがグルーバルオブジェクトの window を参照しているが、Node.js 上では window は未定義なので、エラーになっている
よって、react-routerを使うには Node.js のレンダリングを廃止する必要がある

// pages/_app.tsx
const App = ({ Component, pageProps }: AppProps): JSX.Element => (
  <div suppressHydrationWarning={process.env.NODE_ENV === 'development'}>
    {typeof window === 'undefined' ? null : <Component {...pageProps} />}
  </div>
)

window が undefined の場合、null を返して、レンダリングさせないようにしている

また、上記を行ったことでクライアントとサーバーでレンダリング結果が異なるため、以下のエラーが出る

Warning: Expected server HTML to contain a matching <div> in <div>.

これはsuppressHydrationWarningを指定することで警告を消せる

URL 直接入力の対応

今の状態だと URL 直接入力で画面表示を行う場合、react-routerを通さないと 403/404 エラーになる
たとえば、/task/1でリロードすると、Next.js は/task/1.htmlを探しにいくが、これはないので 403/404 エラーを返す

以下の rewrites 設定を行うことで、すべてのパスをルート(ルーティング書いた場所)に書き換えることができる

// next.config.js
module.exports = {
  async rewrites() {
    return [{ source: '/:path*', destination: '/' }]
  }
}

ただし、rewrites はサーバー上でのみ機能するため、静的サイトホスティングしている場合は機能しない

CloudFront の場合の対応としては、カスタムエラーレスポンスの設定で 403 エラーが起きた場合、レスポンスコードを 200 に変更し、レスポンスページをルート(/)にすればいける

さいごに

サーバーレンダリングを抑制したり、next/routerreact-routerが共存したりして、やってることは微妙かもしれないが、Next.js を利用しつつ、動的ルーティング + URL 直接入力に対応するにはおそらく 1 番簡単なやり方だと思う

react-routerを使わないやり方だと、Lambda@Edge による URL 書き換えや静的サイトホスティングをやめて、ECS 等で Next.js を動かすやり方があると思うが、Lambda の場合は複雑化したり、ECS の場合は途中から切り替えるのはしんどかったりするかなと思う