React Router v7でSSR, CSR, SSGする

# 1. 前提や情報整理


## 1-1. やりたいこと


<a href="https://qiita.com/studio_haneya/items/a9f6f6c1d9aefcd40fd1">チュートリアルをざっとやった</a>ので次はビルド～デプロイを学びたい


## 1-2. SSR, CSR, SSG


最近のフルスタックフレームワークではSSR, CSR, SSGの使い分けを考慮すると思います


これらはビルドやデプロイのやり方が違いますし、コードや設定ファイルでフレームワークに上記を指示するので、これらをよく理解しておく必要があります


|種類                   | 略    | 説明                                |
|-----------------------|-------|------------------------------------|
|Client Side Rendering  | CSR   | ブラウザでJavascriptがレンダリングを　|
|Server Side Rendering  | SSR   | サーバーがhtmlをレンダリングする　    |
|Static Side Generation | SSG   | build時にhtmlをレンダリングする　    |


これらを組み合わせて使うことができるのでより一層ややこしく感じますが、できるデプロイ方法は以下の2つが基本になります


|デプロイ方法       | 例               |SSR   |CSR     |prerender  |
|------------------|-----------------|-------|--------|-----------|
|専用サーバー       |npm run startなど |可能   |可能    |可能       |
|htmlサーバー      |apacheなど        |できない|可能    |可能       |


SSGと言ってしまうとフルにpre-renderingしてhtmlサーバーなりCDNなりで配信する方法全体を指しているように思えるので、ここからはビルド時にhtmlにレンダリングする、という意味をprerenderと表現していきます 


## 1-3. React Router v7ではどう書くか


指示に対する処理は以下のようになります


<b>・SSRしたい情報はloader、CSRしたい情報はclientLoaderで書く</b>
<b>・prerender指定されたページはloaderがprerenderされる</b>
<b>・ssr: falseではloaderが処理できない (エラーになる)</b>


意外と簡単です


|フェッチ方法     | prerender   | 扱い       |
|----------------|-------------|------------|
|clientLoader    | 有効        | CSR       | 
|clientLoader    | 無効        | CSR       |
|loader          | 有効        | prerender |
|loader          | 無効        | SSR       |


## 1-4. SSGはできる？


フェッチをloaderでやっておいてすべてのページをprerenderするようにしたらSSGできてしまいます。これが公式にできるようにしているのか、たまたま出来るようになってしまっているのかは分からないので注意が必要です


実際やってみたのが以下


<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/277233/16982769-a589-8109-53cf-e055d9d913fc.png" width=25%>


ssr: trueなのでserverディレクトリも作られてしまっていますが、build/clientの中身だけで完結しているので、build/clientだけapacheのドキュメントルートに置いてやれば普通に正しく表示されます


詳しいやり方と懸念については後で解説します


# 2. SPA


まずはSPAから見ていきたいと思います


## 2-1. 書き方


SPAについては公式ドキュメントにページがあります
https://reactrouter.com/how-to/spa


ssr: falseとすればSPAになります


```react:react-router.config.ts
import type { Config } from "@react-router/dev/config";

export default {
  ssr: false,
} satisfies Config;
```


ssr: falseのときはloaderが無効になりますし、prerenderを指定してもclientLoaderには関係ないので、ssr: falseでbuildするだけでSPAになるわけです


```terminal:terminal
npm run build
```


buildディレクトリが生成されて、中にビルドされたファイルが配置されます


<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/277233/f34a3dcf-d67d-3e30-7ad2-3e2336c2e269.png" width=30%>


このディレクトリをそのままapacheなどで公開すればSPAとして動作しますが、仮サーバーで確認することもできます


```terminal:terminal
npx vite preview
```


http://localhost:4173 に表示されます


<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/277233/4a939304-a75f-aa12-e1ee-f636f39cf43f.png" width=50%>


## 2-2. clientLoader, clientActionを試す


以前Remix SPAモードで趣味アプリを書いたときにloaderがちゃんと動かないので何故かと思ったら、どうもloaderはSPAモードでは使えないらしいという記載を見つけてあきらめたことがあるのですが (本当にそうかあまり自信がない)、React Router v7はclientLoaderが使えると明記しているのでちゃんと使える筈。試してみたいと思います


### APIを準備


適当なAPIを準備しました
今回は練習なのでCORSは全部許可済みにしてあります


<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/277233/2732d8c1-383b-23c8-8b88-c83329c6f6ba.png" width=50%>


### Route Moduleを準備


clientLoader、clientActionで取得/更新するようにRoute Moduleを書きます


```react:routes/hoge.tsx
import type { Route } from "./+types/home";
import { Form } from "react-router";

const URL = `http://localhost:8000/hoge/`

export async function clientLoader({
  params,
}: Route.ClientLoaderArgs) {
  const res = await fetch(URL);
  const data = await res.json();
  console.log('clientLoader: ', data)
  return data;
}

export async function clientAction({
  request,
}: Route.ClientActionArgs) {
  const formData = await request.formData();  
  const text = formData.get("text")
  console.log('clientAction: ', text)

  const data = { text: text }
  const requestOptions = {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(data)
  };
  try {
    const response = await fetch(URL, requestOptions);
    if (response.ok) {
      const d = await response.json();
      //console.log(d);
    } else {
      throw new Error('Network response was not ok');
    }
  } catch (error) {
    console.error('There was a problem with the fetch operation:', error);
  }
  return null; 
}

export default function Home({
  loaderData,
}: Route.ComponentProps) {
  // return <Welcome />;
  return <div style={{ 
    padding: '50px',
    fontSize: '34px'
  }}>
    <Form method="post">
      <input type="text" name="text" style={{border: '2px solid #333'}} />
      <button type="submit" style={{border: '2px solid #333', marginLeft: '20px'}}>Submit</button>
    </Form>
    { 
      loaderData.map((hoge: { id: number, create_at: Date, text: string }) => 
        { return <div key={hoge.id}>{hoge.text}</div> }
      ) 
    }
  </div>
}
```


ビルドします


```terminal:terminal
npm run build
```


正しく作れているか確認しましょう


```terminal:terminal
npx vite preview
```


上手く動いているようです


<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/277233/82d7074a-b0f2-3436-545f-467a294e7c9c.png" width=40%>


actionでデータに更新がかかると自動的にloaderが動いて表示が更新されます
これは便利


<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/277233/8bba9e84-b845-70b1-78bd-b005294014b1.png" width=40%>


### 2-3. routingを試す


適当にルーティングを書きます


```routes.ts
import { 
  type RouteConfig, 
  index,
  route,
} from "@react-router/dev/routes";

export default [
  index("routes/home.tsx"),
  route("/hoge", "routes/hoge.tsx"),
  route("/fuga", "routes/fuga.tsx"),
  route("/todos", "routes/todos.tsx"),
] satisfies RouteConfig;
```


適当なRoute Moduleを書きます


```routes/hoge.tsx
import type { Route } from "./+types/home";
import { Link } from "react-router";

export default function Home() {
  return <div style={{ 
    padding: '50px',
    fontSize: '34px'
  }}>
    <Link to="/">
      HOGE
    </Link>
  </div>
}
```


ビルドします


```terminal:terminal
npm run build
```


ビルドするとindex.htmlだけが生成されます


<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/277233/53e933b6-c0f0-ff59-415b-7e11c5ad9137.png" width=40%>


正しく作れているか確認しましょう


```terminal:terminal
npx vite preview
```


以下を開くと
http://localhost:4173/hoge


こんな感じ
ちゃんと出来てますね


<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/277233/60a54bd5-e1af-d5e9-376c-b1b6475f35ec.png" width=40%>


## 3. SSG


pre-renderについては公式ドキュメントにページがあります
https://reactrouter.com/how-to/pre-rendering


公式ドキュメントにはSSGができると明記されていないが、ssr: trueにしてすべてのページをprerenederの対象にすれば結果としてSSGはできるようです


### 3-1. 動的ルーティングがない場合


loaderが使えるようにssr: trueにして、ページすべてをprerender対象にします


```react:react-router.config.ts (動的ルーティングがない場合)
import type { Config } from "@react-router/dev/config";

export default {
  ssr: true,
  prerender: true,
} satisfies Config;
```


これでビルドすればpre-renderしてくれます


```terminal:terminal
npm run build
```


### 3-2. 動的ルーティングがある場合


prerender: trueは動的ルーティングするページはやってくれないので、ブログのようなCMSの場合は動的ルーティングするページのリストを作って渡してやることになります


```react:react-router.config.ts (動的ルーティングがある場合)
import type { Config } from "@react-router/dev/config";

const makePrerenderList = async () => {
  const URL = `http://localhost:8000/hoge/`
  const res = await fetch(URL);
  const data = await res.json();
  const toPrerender = ["/"].concat(
    data.map((hoge: { id: number, create_at: Date, text: string }) => "/"+hoge.id)
  );
  console.log(toPrerender)
  return toPrerender
}

export default {
  ssr: true,
  async prerender({ getStaticPaths }) {
    return makePrerenderList()
  }  
} satisfies Config;
```


ビルドします


```terminal:terminal
npm run build
```


build/clientディレクトリにSSGされた内容が書き出されています。ssr: trueなのでbuild/serverディレクトリもありますが、build/clientディレクトリの中身だけapacheで公開すると正常に動作したので問題なさそうです


<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/277233/b6a0d77f-cd2f-1a1e-276f-f4a94b0d586d.png" width=25%>


### 3-3. 懸念


上記でビルドしてからnpx vite previewすると、SSRで公開しようとしてしまうので正常に表示されません。また、公式ドキュメントでもSSGできると書かれておらず、検索しても開発チームからそれらしき発言がありませんし、React Router v7にマージされたRemix v2側は明確にSSGに対応しない考えを表明しています。この事から、SSGできるというのは開発チーム側がそうあるべきとして作っているのでなく、たまたま出来てしまっている可能性も多分にあると思われます


「SSGできるじゃんわーい」とプロダクションに導入してしまった場合、ある日のバージョンアップでできなくなってしまうかもしれません。仕事で使うのであれば明確に対応を表明しているAstroなどを使うか、逆にCloudflare WorkersなどでSSRとして公開するのが無難だし、SEOが関係ないならシンプルにSPAで作るのが良いと思います


## 4. SSR


SSRしようと思うとサーバーが必要になりますが、テンプレートリポジトリがGithubに公開されていて、公開方法ごとの手順が分かるようにしてくれています
https://github.com/remix-run/react-router-templates/tree/main


ssr: trueにしておくと、CSR, SSRをclienetLoader, loaderの選択で、prerenderをそれぞれページ単位で任意に設定できます


### 4-1. dockerでNode.jsサーバーを立てる


手順は以下リンクにあります
https://github.com/remix-run/react-router-templates/tree/main/node-custom-server


React Router v7でプロジェクトをつくると以下のようなファイル構成になり、最初から作られている謎のDockerfileがありますが、これを使います


<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/277233/8eedbbf5-5174-78b7-a092-6e312fcbada2.png" width=30%>


Dockerfileの中身を見ると、以下のようにマルチステージビルドでビルドしてからnpm run startするようになっています。docker compose upするときもそのまま呼べる書き方になっていて最高です


```dockerfile:Dockerfile
FROM node:20-alpine AS development-dependencies-env
COPY . /app
WORKDIR /app
RUN npm ci

FROM node:20-alpine AS production-dependencies-env
COPY ./package.json package-lock.json /app/
WORKDIR /app
RUN npm ci --omit=dev

FROM node:20-alpine AS build-env
COPY . /app/
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
WORKDIR /app
RUN npm run build

FROM node:20-alpine
COPY ./package.json package-lock.json /app/
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
COPY --from=build-env /app/build /app/build
WORKDIR /app
CMD ["npm", "run", "start"]
```


使うときは以下


```terminal:terminal
docker build -t my-app .
docker run -p 3000:3000 my-app
```


### 4-2. それ以外の方法


ホスティングサービスなどを使った手順も以下に書かれています
https://github.com/remix-run/react-router-templates/tree/main


リポジトリにフォルダがたくさんありますので自分が使いたいデプロイ方法を選ぶとreadme.mdに使い方がめちゃめちゃ分かりやすく書いてあります


<a href="https://github.com/remix-run/react-router-templates/tree/main">
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/277233/fec0ba09-2494-65a2-36ec-25d785ed57c9.png" width=40%>
</a>


# まとめ 


SPA, SSG, SSRの書き方について整理してみました


SPAとSSRは公式に対応していますし、SSRを基本としてCSRとprerenderを組み合わせることもできます。SSGは多分ダメなんじゃないかと思いますが一応できますし、Astro使えばSSGできますから、loader, actionが好きならReact Router v7はかなり良いと思います


書き方が整理されていて分かりやすくなっているのも魅力で、SPA, SSRを書く際に選ばれることが増えていくのではないかと思います。React19でRCSが登場して更に複雑さを増やす方向になっていますが、Remix v3もRCSに対応してリライトされる予定であるなど各フレームワークがRCSに対応してくるでしょうし、今回整理が進んだように分かりやすさと高機能が両立するようになっていくのではないかと期待しています


レッツトライ



# 余談


SSRと言ったとき以下のような概念が混じっていて書きにくいと感じました


・サーバーサイドでフェッチを行うこと
・フェッチした情報をサーバーサイドでhtmlにレンダリングすること
・SSRを行うアプリケーションデプロイ方式のこと


似たようなことを感じている人もいるようです
https://zenn.dev/sumiren/articles/349c60f19c505f


こういうのを読むとフロントエンドフレームワークは新しい方式がどんどん出てきて概念整理がまだ足りない状態なのかなと思います。ルーティングのあるSPAというのも改めて考えるとトゲナシトゲトゲ的な面白呼称ですし、SSRアプリはSSRだけでなくCSRやprerenderも混在できるわけで、本当にSSRという呼び方で良いのか？というのは皆思うところだと思います


もう少ししたら整理が進んでいくのかなと思ったところでRSC登場ですから、まだまだ「フロントエンド難しいよね」といわれる状態が続きそうでありますが、少なくとも書いてる我々は混乱しないようにしていきたいですね