Next.jsでbodyタグの属性切り替えはどうするの?

最近なにかとお触りしてるNext.js。
Reactを扱いやすく、特にルーティング周りが簡易的にできるフレームワークとあって採用。

SEO対策は不要なので完全にSSG(Server Side Generation)に振り切ってる。
ただ、厳密にはサーバー側に必要な情報はフェッチする必要があるのでSSG + CSR(Client Site Rendering)となってる。

SSG / SSR / CSR

SSG(Static Site Generation)
ビルド時に事前にHTMLを生成してしておく。静的ページなのでCDNのキャッシュが効くのでパフォーマンス(画面描画速度)が良い、早い。
動的コンテンツの生成に向かないため、SEO的には不利。
getStaticPropsというファンクションで外部データからデータを取り込んだ上でビルドし、静的データに置換しておける。すごい。

SSR(Server Side Rendering)
リクエストの都度HTMLを生成し、それをフロントに返す。データもバインドして描画するので、動的コンテンツもへっちゃら。
SEO対策が必要な場合はSSRが必須といえる。

CSR(Client Side Rendering)
クライアント側で必要なタイミングでDOMをレンダリングする仕組み。SSGだけど外部からデータをフェッチしたいときに使う。
実際はuseEffect内でaxiosなどをつかってデータを取得しStateでバインドしてレンダリングする。
Next.js(Vercel社)はuseSWRライブラリを推奨している。
※useSWRはうまく非同期でデータを取得するエラーハンドリングやキャッシュの機能をラップ(内包)してはくれているが

Next.jsではhtmlやbodyタグなど、どの画面でも共通かつ不変要素は基本的に_document.tsxに書くのが望ましいのです。(拡張子がtsxなのはtypescriptで書かれてるからです。本来はjsx)

_document.tsxは共通タグの定義(揺るぎないタグの生成)
_app.tsxは画面共通で走らせる処理の定義(セッション状態の監視など、揺るぎない共通処理)

と理解しておけば棲み分けには困らない。

_document.tsx内では属性切り替えはできない

当然といえば当然なのですが、先述の通り_document.tsxは共通タグを定義しておくため、動的に変わる属性というのは定義できません。
例えば以下の感じ。

import Document, { Html, Head, Main, NextScript } from 'next/document'

class MyDocument extends Document {
  render() {
    return (
      <Html>
        <Head />
        <body data-page='hoge'>
          <Main />
          <NextScript />
        </body>
      </Html>
    )
  }
}

export default MyDocument

ここでいうdata-page=’hoge’は全画面共通であるため、例えばStyleClassでdata-pageの属性によってスタイルを切り分けたりしてると詰みます。

なので各pageで<body>要素から書いたり色々とやってたりしたのですが、そうすると

<body data-page="hoge">
  <div id="__next">
    <body>

という感じでbodyタグが2つできたりするわけです。
※これは_document.tsxからbodyタグを削除しても同じ。その場合、各pageで書かれたbodyが<div id="__next">の上位にも生成される。(結局bodyが2つになる)

どうでも良いし、もしかすると理解が違ってるかもしれませんが、どうやらNext.jsのルーティング(画面切り替え)は<div id="__next">配下の要素をリレンダリングすることで実現してるっぽいです。

解決策

じゃあ結局どうやるのさって話です。
結論を言うと

・_document.tsxのbodyタグにid属性をつける
・_app.tsxのuseEffectでbodyタグの要素を付け替える

ということをやります。
以下のようにします。

_document.tsx
class Document extends NextDocument<Props> {
  render() {
    return (
      <Html>
        <Head></Head>
        <body id='hoge_body' data-page=''>
          <Main />
          <NextScript />
        </body>
      </Html>
    );
  }
}
_app.tsx
import { updateBodyData } from 'src/libs/Hooks/updateBodyData';
..
const App = ({ Component, pageProps, router }: AppProps) => {
  // パスを見てdata-page属性にセットする値を変えるフック処理
  updateBodyData();
  return <Component {...pageProps} />;
};
src/libs/Hooks/updateBodyData
import { useRouter } from 'next/router';
import { useEffect } from 'react';

/**
 * 画面レンダリングの際のフック処理
 */
export function updateBodyData() {
  const { pathname } = useRouter();

  useEffect(() => {
    // パスによってbodyのdata属性を切り替える
    let element = document.getElementById('hoge_body');

    element.dataset.page = fetchDataPage(pathname);
  }, [pathname]);
}

export const fetchDataPage = (pathname: string): string => {
  switch (pathname) {
    case '/':
      return 'home';
    case '/auth/login':
      return 'login';
    default:
      return '';
  }
};

bodyタグを切り替えるライブラリを別に作っておくとコードの見通しが良きです。
_app.tsxで呼び出すだけ。
描画される画面のパスを見てdata属性にセットする値を切り分けています。

フック処理をuseEffect外に書いてしまうと
SSR(サーバサイドレンダリング)と認識されビルドに失敗するので注意です。

これで各画面に応じたbodyのdata属性を切り替えることができました。

めでたし、めでたし。