Remixのアプリをサーバーなしで静的ホスティングに載せたいときに使うのがSPAモードです。設定は ssr: false の1行ですが、loader と action の export はビルドエラーになり、配信される初期HTMLにはルートルートより下の内容が入りません。しかもRemix v2の後継はReact Routerに移っており、同じ ssr: false でもv2とReact Routerではビルド結果が違います。この記事では、Remix 2.17.5とReact Router 8.5.1で同じ構成をビルドし、出力ファイルとエラー文を確かめたうえで、設定・制約・配信・移行の判断を整理します(確認日は2026年11月2日)。
まとめ:Remix SPAモードの要点
- SPAモードはRemix 2.5.0で追加され、Remix Viteプラグインで
ssr: falseを指定して有効にします。2.5.x/2.6.xではunstable_ssrという名前でした。 headers・loader・actionをexportするとビルドが失敗します。データの取得と更新はclientLoader・clientActionで書きます。index.htmlはルートルートのHydrateFallbackをビルド時にレンダリングして作られます。検索エンジンに見せたいページの内容は初期HTMLに入りません。- 後継のReact Router(最新8.5.1)ではルートルートだけ
loaderを書け、prerenderと組み合わせれば指定したパスを静的HTMLにできます。 - 新規開発でRemix v2のSPAモードを選ぶ理由はほぼありません。既存アプリは
npx codemod remix/2/react-router/upgradeでReact Routerへ移せます。
SPAモードが追加された背景と位置づけ
Remixはサーバーで動かす前提のフレームワークです。それでもRemix v2の公式ガイドは、サーバーを運用したくない場合やCDNへ静的ファイルとして配信したい場合を妥当な用途と認め、Client Data APIを土台にSPAモードを2.5.0で追加したと説明しています。同ガイドによれば、中身はReact RouterとViteで createBrowserRouter を自前構成したものに、ファイルベースルーティング、ルート単位のコード分割、<Link prefetch>、<Meta>/<Links> によるhead管理を足したものです。
反対に、SPAモードではRemixらしい機能をいくつか手放します。サーバーでフォームを受け取れないので、JavaScriptが無効でも動くプログレッシブエンハンスメントは成立しません。サーバーで発行するセッションやHttpOnlyクッキー、サーバー側でのリダイレクトも使えません。サーバーでのデータ取得やストリーミングも使えず、データ取得はハイドレート後に clientLoader から始まるので、初回表示までの待ち時間はSSRより長くなります。SPAとMPAの設計の違いはPESPAとSPA・MPAの違いとReact Router v8・Remix 3での実装で、SPAという方式そのものの選び方はSPAの開発言語・フレームワークの選び方とMPA・SSRとの違いで扱っています。
ssr:falseの設定とビルド結果
Remix v2のvite.config.tsでの有効化
Remix v2では、Viteプラグインのオプションに ssr: false を渡します。
import { vitePlugin as remix } from "@remix-run/dev";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [remix({ ssr: false })],
});
フラグ名は途中で変わりました。RemixのCHANGELOGにある2.7.0の項目「Vite: Stabilize “SPA Mode” by renaming the Remix vite plugin config from unstable_ssr -> ssr」が境目です。逆向きの間違いにも注意が要ります。2.17.5で古い記事どおり unstable_ssr: false と書くと、警告なしに無視され、通常のサーバーレンダリング用ビルドとして処理されます。@remix-run/node などのサーバーランタイムを依存に入れていない構成では「Could not determine server runtime」で止まります。ランタイムを依存に持つ構成ではSSR用のビルドが進み、build/client/index.html は作られません。
Remix v2の公式ガイドにある npx create-remix@latest --template remix-run/remix/templates/spa は、create-remix 2.17.0以降はプロジェクトを作らず、React Routerを使うよう案内を表示して終わります(2.17.5で確認)。新規に作るなら次のReact Routerで始めてください。
React Routerのreact-router.config.tsでの設定
React Routerのフレームワークモードでは、設定先が react-router.config.ts に移ります。
import type { Config } from "@react-router/dev/config";
export default {
ssr: false,
} satisfies Config;
同じ最小構成(ルートルートと子ルート2本)を両方でビルドすると、出力は次のように違いました。
| 項目 | Remix 2.17.5 | React Router 8.5.1 |
|---|---|---|
| ビルドコマンド | remix vite:build |
react-router build |
| ビルド後のログ | index.html has been written | Generated build/client/index.html |
| build/client/index.html | あり | あり |
| build/server/ | 削除される | index.js が残る |
ルートの loader |
ビルドエラー | 使える(ビルド時に実行) |
React RouterのSPAガイドは「With this set to false, the server build will no longer be generated.」と書いていますが、8.5.1では build/server/index.js が残りました。デプロイ対象は build/client だけなので実害はありませんが、成果物を丸ごとアップロードするスクリプトでは除外しておきます。なお ssr: true のままビルドすると build/client/index.html は生成されません。静的ホストでトップページが開けない場合は、まず設定値を確かめてください。
HydrateFallbackとルートloaderで作るindex.html
ビルドすると、フレームワークは / をビルド時にレンダリングし、その結果を build/client/index.html に保存します。このときルートルートの HydrateFallback が描画されます。React Routerでは、ルートルートの loader もこのビルド時に1回だけ実行され、戻り値を HydrateFallback の loaderData で受け取れます(7.2.0で追加)。
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router";
import type { Route } from "./+types/root";
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="ja">
<head><meta charSet="utf-8" /><Meta /><Links /></head>
<body>{children}<ScrollRestoration /><Scripts /></body>
</html>
);
}
export async function loader() {
return { builtAt: new Date().toISOString() };
}
export function HydrateFallback({ loaderData }: Route.HydrateFallbackProps) {
return <p>読み込み中(ビルド {loaderData?.builtAt})</p>;
}
export default function App() {
return <Outlet />;
}
8.5.1でこの形をビルドすると、loader の戻り値は index.html の中に埋め込まれ、子ルート(トップページ本体)の内容は含まれませんでした。公式ガイドの説明どおり、生成されるHTMLはルートルートまでで止まります。1枚の index.html をどのパスでも配信してハイドレートできるようにするための設計です。root.tsx を Layout なしで書く場合は、HydrateFallback と既定のコンポーネントの両方に <Scripts /> を置きます。置き忘れるとビルドが止まり、Remix 2.17.5では「SPA Mode: Did you forget to include <Scripts/> in your root.tsx HydrateFallback component?」、React Router 8.5.1では「SPA Mode: Did you forget to include <Scripts/> in your root route?」が出ます。
SPAモードで使えないexportとビルドエラー
SPAモードで最もつまずきやすいのがexportの制限です。両方で禁止されたexportを書き、出たエラーを並べます。
| 書いたもの | Remix 2.17.5 | React Router 8.5.1 |
|---|---|---|
子ルートの loader・action |
ビルドエラー | ビルドエラー |
ルートルートの loader |
ビルドエラー | 使える |
子ルートの HydrateFallback |
ビルドエラー | ビルドエラー |
描画中の window 参照 |
ビルド時に失敗 | ビルド時に失敗 |
React Router 8.5.1のエラー文は「SPA Mode: 2 invalid route export(s) in routes/about.tsx: loader, action.」、Remix 2.17.5でルートルート(root.tsx)に loader を置いたときは「SPA Mode: 1 invalid route export(s) in root.tsx: loader.」でした。子ルートの HydrateFallback には「HydrateFallback is only permitted on the root route in SPA Mode.」が出ます(両系列とも同じ文)。Remix 2.17.5の子ルートの loader・action の行は、同じ検査処理をプラグインのソースで確認したものです。
「SPAモードでもloaderでデータを取得できる」と書いた記事や、SPAモードの節に export async function loader を載せたサンプルは、どちらの系列でも子ルートではビルドが通りません。もう1つの制約として、clientLoader/clientAction から serverLoader/serverAction を呼ぶと、動いているサーバーが無いため実行時エラーになります。
clientLoader・clientActionによるデータ取得と更新
データの取得と更新はClient Data APIで書きます。params・request の受け取り方はサーバー版と同じです。違いは、引数に serverLoader が加わる代わりにサーバーの context が無いことと、戻り値がシリアライズを経ないため Date や Map をそのまま返せることです。
export async function clientLoader({ params }) {
const res = await fetch(`/api/items/${params.id}`);
if (!res.ok) throw new Response("Not Found", { status: res.status });
return res.json();
}
export async function clientAction({ request }) {
const formData = await request.formData();
const res = await fetch("/api/items", { method: "POST", body: formData });
return res.json();
}
既存のReact Routerアプリから寄せる場合、Remix v2ガイドの「Migrating from React Router」節(RouterProvider 構成のアプリ向け)は「loader/action を clientLoader/clientAction にリネームする」手順を示しています。再検証のタイミングや clientLoader と loader の優先順位はReact Routerのaction・loaderの使い方とclientLoaderとの違いで詳しく扱っています。APIキーを使う外部サービスやデータベースへの接続はブラウザに置けないため、SPAモードを選ぶなら別のAPIサーバーが前提になります。
SPAフォールバックの配信設定とビルド時レンダリングの失敗
すべてのパスをindex.htmlへ返す設定
静的ホストは、存在しないパス(/about など)への要求にも index.html を返す必要があります。これをSPAフォールバックと呼びます。公式は確認用に sirv-cli を挙げています。
npx sirv-cli build/client/ --single
Netlifyなど _redirects に対応したホストでは、React Routerのガイドの例どおり /* /index.html 200 の1行で指定します。リロードすると404になるなら、原因はほぼこの設定です。S3とCloudFrontの組み合わせでは、オリジンが返す403/404を /index.html(ステータス200)に置き換えるカスタムエラーレスポンスで同じことを指定します。Cloudflareへの載せ方はCloudflare Pagesの使い方と静的サイトの公開手順、AWSの場合は書き換え規則の書き方が独特なのでRemix SPAモードとAmplify Gen 2の構成手順を参照してください。
ビルド時のwindow・document参照エラー
ssr: false が止めるのは実行時のサーバーレンダリングだけです。index.html を作るためにビルド時のレンダリングは走るので、描画中に window や localStorage を触るとビルドが失敗します。8.5.1で HydrateFallback 内に window.innerWidth を書いたところ、「ReferenceError: window is not defined」に続いて「SPA Mode: Received a 500 status code from entry.server.tsx while prerendering your SPA Fallback HTML file.」が出て止まりました。
Remix v2のガイドは、ブラウザ専用ライブラリを entry.client.tsx から読み込む、React.lazy を使う、remix-utilsの <ClientOnly> で囲む、の3つを回避策に挙げています。useEffect の中で参照する形に直すのが最も手軽です。依存パッケージのCJS/ESMの問題で落ちる場合は、Viteの ssr.noExternal にそのパッケージを指定します。ハイドレーション時の不一致エラーの読み方はハイドレーションエラーの原因別の直し方にまとめています。
SPAモードのSEOの限界とprerender併用
初期HTMLに入らないページ本文とtitle
SPAモードの初期HTMLは、ビルド時にルートルートから作った1枚だけです。「SPAモードでもSSRでSEOを強化できる」という説明は成り立ちません。クローラが最初に受け取るHTMLには記事本文も商品情報も入らず、ページごとのタイトルの出し分けもJavaScriptの実行後になります。URLごとに違う内容を検索結果に出したいならSPAモードは選ばず、認証後の管理画面や社内ツールのようにインデックス不要な画面に限るのが判断の基準です。レンダリング方式の選び方全体はReact SEO対策とSSR・SSG・プリレンダリングの選び方とSSR・CSR・SSG・ISR・ISGの違いを参照してください。
prerenderによる一部パスの静的HTML化
React Routerでは、ssr: false のまま prerender を指定できます。8.5.1で次の設定をビルドしました。
export default {
ssr: false,
prerender: ["/", "/posts"],
} satisfies Config;
| パス | HTML | データ |
|---|---|---|
| / | build/client/index.html | build/client/_.data |
| /posts | build/client/posts/index.html | build/client/posts.data |
| それ以外 | build/client/__spa-fallback.html | なし |
/posts のHTMLには loader の結果が描画済みで入っていました。/ を事前生成すると index.html はトップページ専用になるため、残りのパスのフォールバックには __spa-fallback.html を返すよう設定します(sirv-cliなら --single __spa-fallback.html)。
制約も残ります。事前生成しないルートに loader を書くと「Invalid route exports found when prerendering with ssr:false」で失敗し、事前生成するルートでも action は同じエラーになりました。8.5.1では、事前生成していないパラメータ付きパスをフォールバック経由で開いたときに内部エラー「No result found for routeId」が出る問題が直っています(PR #15646)。prerender を使うなら8.5.1以降にしておくのが無難です。
metaの配列形式とv8のloaderData一本化
head管理は両系列とも meta のexportで行い、戻り値はオブジェクトではなく配列です。v1形式の return { title, description } は動きません。
import type { MetaFunction } from "react-router"; // v2は "@remix-run/react"
export const meta: MetaFunction = () => [
{ title: "商品一覧 | Example" },
{ name: "description", content: "商品の一覧ページです" },
{ property: "og:title", content: "商品一覧" },
];
React Router v8では、meta の引数 data と matches[].data が廃止され loaderData に一本化されました。v7からのその他の変更はReact Router v8のmeta・title設定と基本ルーティングで確認できます。
Remix v2のSPAモードからReact Routerへ移る判断
codemodの範囲と移行先の要件
React RouterのRemixからの移行ガイドは、v7を「Remix v2の次のメジャーバージョン」と位置づけています。Remix v2のfutureフラグをすべて有効にしてあれば、作業の中心は依存とimport元の入れ替えで、codemodがほぼ自動化します。
npx codemod remix/2/react-router/upgrade
codemodは依存とimportの書き換えに加え、remix vite:build を react-router build に、RemixBrowser を HydratedRouter に置き換えます。SPAモードの場合は、vite.config.ts の ssr: false を react-router.config.ts へ移してください。
codemodと移行ガイドの移行先はv7です。v8へは、v7系の最新に上げたあと公式の「Upgrading from v7」に沿って future.v8_* フラグを有効にしてから上げる2段階になります。移行先は要件で選びます。v7は node 20/React 18から動きます。v8は node 22.22以上、React 19.2.7以上、フレームワークモードでは vite 7以上が必要です。2026年11月2日時点のnpm公開版は、react-router が8.5.1(日本時間10月28日)、v7系が7.18.5(同日)で、両系列とも更新が続いています。React 19に上げられない間はv7系の最新パッチに寄せ、上げられるならv8に進むのが手堅い順序です。ルーター自体を選び直すならTanStack Routerの特徴とReact Routerとの違いも比較対象になります。
新規でRemix v2を選ぶべきでない条件
次のどれかに当てはまるなら、Remix v2のSPAモードで新しく始めるのはやめてReact Routerを使ってください。ルートで設定値や初期データをビルド時に読みたい(v2はルートの loader もエラー)、一部のページだけ検索エンジンに見せたい(prerender はReact Router側の機能)、React 19以降で開発したい、のいずれかです。v2系の @remix-run/react は2.17.5(2026年6月1日公開)が確認時点の最新です。
「Remix」という名前の今の扱いにも注意が要ります。npmの remix パッケージは latest が3.1.0(日本時間2026年10月24日)を指し、これはReactに依存しない別設計の「Remix 3」です。v2のアプリは @remix-run/react などに依存しているので自動では移りませんが、npm i remix で入るのはv2ではありません。なおRemix 3の remix パッケージには remix/spa(@remix-run/spa、説明は「Client-rendered application routing for Remix」)というクライアント描画用の別APIがあり、v2の ssr: false とは仕組みも書き方も別物です。Remix 3の書き方はRemix 3のコンポーネントとhandle・mixの書き方で扱っています。
SPAのフロントエンドのまま、サーバー側の loader/action だけは使いたい場合、React Router 8.5.0で ssr: "unstable_api-only" が追加されました。ただし名前のとおり不安定版の機能で、CHANGELOGも本番利用を推奨していません。現時点では検証用にとどめるべきです。
SPAモードと事前レンダリングに関係する脆弱性
SPAモードと prerender の周辺では、次の脆弱性がGitHub Security Advisoriesで公表されています。
| 識別子 | 内容 | react-router 対象 | 修正版 | 公表日 |
|---|---|---|---|---|
| CVE-2025-43864 | SPAモード強制によるキャッシュ汚染 | 7.2.0〜7.5.1 | 7.5.2 | 2025-04-24 |
| CVE-2025-43865 | 事前レンダリングデータのなりすまし | 7.0〜7.5.1 | 7.5.2 | 2025-04-24 |
| CVE-2026-21884 | ScrollRestoration経由のXSS | 7.0.0〜7.11.0 | 7.12.0 | 2026-01-08 |
| CVE-2026-33244 | 事前生成リダイレクトHTMLのXSS | 7.5.1以上7.13.2未満 | 7.13.2 | 2026-06-02 |
CVE-2025-43864は X-React-Router-SPA-Mode ヘッダーでSSRアプリをSPAモードに切り替えさせる攻撃で、SSRかつ loader を持つページが対象です。CVE-2026-21884はサーバーレンダリング時に発火し、アドバイザリはサーバーレンダリングを無効にしたアプリは影響を受けないと明記しています。この件はRemix v2も対象で、@remix-run/react 2.17.2以下が該当し2.17.3で修正されました。CVE-2026-33244は prerender で生成したリダイレクト用HTMLに、信頼できないリダイレクト先をそのまま書き込む場合のXSSです。
上の表はSPAモードと事前レンダリングに関わるものだけです。react-routerでは2026年7月22日にもDoSやXSSのアドバイザリが6件まとめて公表されており、表の修正版に上げれば済むわけではありません。v7系なら7.18.5、v8系なら8.5.1のように、系列内の最新パッチに追随してください。検索で見かける GHSA-qwww-vcr4-c8h2 は同日公表のRSCモードのCSRFで、修正版は7.18.2と8.3.0です。アドバイザリは不安定版のRSC APIを使っている場合だけが対象だとしています。
よくある質問
Remix SPAモードとReact RouterのSPAモードは何が違いますか?
設定ファイルが vite.config.ts から react-router.config.ts に変わり、React Routerではルートルートに限って loader を書けます。prerender との併用もReact Router側の機能です。8.5.1の実測では build/server/index.js が残る点も違いました。
SPAモードでloaderを使うとどうなりますか?
子ルートに書くと、Remix v2でもReact Routerでもビルドが「SPA Mode: N invalid route export(s) in …」の形のエラーで止まります。React Routerのルートルートだけは例外で、ビルド時に1回実行されます。画面表示のたびにデータを取るなら clientLoader を使ってください。
SPAモードでもSEO対策はできますか?
SPAモード単体では、初期HTMLにページの内容が入らないため不向きです。React Routerなら prerender で検索に出したいパスだけ静的HTMLにでき、残りは __spa-fallback.html で配信できます。
remix spa modeのビルド結果はどこに出力されますか?
どちらも build/client/ で、index.html もこの直下にできます。静的ホストへ配置するのはこのディレクトリだけで、Node.jsのプロセスを常駐させる必要はありません。
既存のRemix v2 SPAアプリはReact Routerへ移行すべきですか?
futureフラグを有効にしてあれば作業量は小さく、npx codemod remix/2/react-router/upgrade が使えます。React 18のままならv7系で止め、React 19.2.7以上に上げられるなら、いったんv7へ移したあと「Upgrading from v7」の手順でv8へ上げます。