diff --git a/.changeset/spa-mode.md b/.changeset/spa-mode.md
new file mode 100644
index 00000000000..efb09301a3f
--- /dev/null
+++ b/.changeset/spa-mode.md
@@ -0,0 +1,42 @@
+---
+"@remix-run/dev": minor
+"@remix-run/react": minor
+"@remix-run/server-runtime": minor
+"@remix-run/testing": minor
+---
+
+Add unstable support for "SPA Mode"
+
+You can opt into SPA Mode by setting `unstable_ssr: false` in your Remix Vite plugin config:
+
+```js
+// vite.config.ts
+import { unstable_vitePlugin as remix } from "@remix-run/dev";
+import { defineConfig } from "vite";
+
+export default defineConfig({
+ plugins: [
+ remix({ unstable_ssr: false }),
+ ],
+});
+```
+
+Development in SPA Mode is just like a normal Remix app, and still uses the Remix dev server for HMR/HDR:
+
+```sh
+remix vite:dev
+```
+
+Building in SPA Mode will generate an `index.html` file in your client assets directory:
+
+```sh
+remix vite:build
+```
+
+To run your SPA, you serve your client assets directory via an HTTP server:
+
+```sh
+npx http-server build/client
+```
+
+For more information, please refer to the [SPA Mode docs][https://reactrouter.com/en/main/guides/spa-mode].
\ No newline at end of file
diff --git a/docs/guides/client-data.md b/docs/guides/client-data.md
index 88da8d4c856..48257b5cf58 100644
--- a/docs/guides/client-data.md
+++ b/docs/guides/client-data.md
@@ -12,7 +12,7 @@ These new exports are a bit of a sharp knife and are not recommended as your _pr
- **Fullstack State:** Augment server data with client data for your full set of loader data
- **One or the Other:** Sometimes you use server loaders, sometimes you use client loaders, but not both on one route
- **Client Cache:** Cache server loader data in the client and avoid some server calls
-- **Migration:** Ease your migration from React Router -> Remix SPA -> Remix SSR (once Remix supports [SPA mode][rfc-spa])
+- **Migration:** Ease your migration from React Router -> Remix SPA -> Remix SSR (once Remix supports [SPA Mode][rfc-spa])
Please use these new exports with caution! If you're not careful - it's easy to get your UI out of sync. Remix out of the box tries _very_ hard to ensure that this doesn't happen - but once you take control over your own client-side cache, and potentially prevent Remix from performing it's normal server `fetch` calls - then Remix can no longer guarantee your UI remains in sync.
@@ -226,13 +226,13 @@ export async function clientAction({
## Migration
-We expect to write up a separate guide for migrations once [SPA mode][rfc-spa] lands, but for now we expect that the process will be something like:
+We expect to write up a separate guide for migrations once [SPA Mode][rfc-spa] lands, but for now we expect that the process will be something like:
1. Introduce data patterns in your React Router SPA by moving to `createBrowserRouter`/`RouterProvider`
2. Move your SPA to use Vite to better prepare for the Remix migration
3. Incrementally move to file-based route definitions via the use of a Vite plugin (not yet provided)
-4. Migrate your React Router SPA to Remix SPA mode where all current file-based `loader` function act as `clientLoader`
-5. Opt out of Remix SPA mode (and into Remix SSR mode) and find/replace your `loader` functions to `clientLoader`
+4. Migrate your React Router SPA to Remix SPA Mode where all current file-based `loader` function act as `clientLoader`
+5. Opt out of Remix SPA Mode (and into Remix SSR mode) and find/replace your `loader` functions to `clientLoader`
- You're now running an SSR app but all your data loading is still happening in the client via `clientLoader`
6. Incrementally start moving `clientLoader -> loader` to start moving data loading to the server
diff --git a/docs/guides/spa-mode.md b/docs/guides/spa-mode.md
new file mode 100644
index 00000000000..cec28e80f6a
--- /dev/null
+++ b/docs/guides/spa-mode.md
@@ -0,0 +1,124 @@
+---
+title: SPA Mode
+---
+
+# SPA Mode
+
+From the beginning, Remix's opinion has always been that you own your server architecture. This is why Remix is built on top of the [Web Fetch API][fetch] and can run on any modern [runtime][runtimes] via built-in (or community-provided) adapters. While we believe that having a server provides the best UX/Performance/SEO/etc. for _most_ apps, it is also undeniable that there exist plenty of valid use cases for a Single Page Application in the real world:
+
+- You prefer to deploy your app via static files on Github Pages or another CDN
+- You don't want to manage a server, or run a Node.js server
+- You're developing a special type of embedded app that can't be server rendered
+- "Your boss couldn't care less about the UX ceiling of SPA architecture and won't give your dev teams time/capacity to re-architect things" [- Kent C. Dodds][kent-tweet]
+
+That's why we added support for **SPA Mode** in [2.5.0][2.5.0] (per this [RFC][rfc]), which builds heavily on top of the [Client Data][client-data] APIs.
+
+## What is SPA Mode?
+
+SPA Mode is basically what you'd get if you had your own [React Router + Vite][rr-setup] setup using `createBrowserRouter`/`RouterProvider`, but along with some extra Remix goodies:
+
+- File-based routing (or config-based via [`routes()`][routes-config])
+- Automatic route-based code-spitting via [`route.lazy`][route-lazy]
+- `
` management via Remix [``][meta]/[``][links] APIs
+ - you don't _have_ to do this if your app doesn't warrant it - you can still just render and hydrate a `
` with some minor changes to `root.tsx` and `entry.client.tsx`
+
+SPA Mode tells Remix that you do not plan on running a Remix server at runtime and that you wish to generate a static `index.html` file at build time and you will only use [Client Data](https://remix.run/docs/en/main/guides/client-data) APIs for data loading and mutations.
+
+The `index.html` is generated from your `root.tsx` route. You **should** include a `HydrateFallback` component in `root.tsx` containing the app shell/initial loading state. The initial "render" to generate the `index.html` will not include any routes deeper than root. This ensures that the `index.html` file can be served/hydrated for paths beyond `/` (i.e., `/about`) if you configure your CDN/server to do so.
+
+## Usage
+
+You can opt into SPA Mode by setting `unstable_ssr: false` in your Remix Vite plugin config:
+
+```js
+// vite.config.ts
+import { unstable_vitePlugin as remix } from "@remix-run/dev";
+import { defineConfig } from "vite";
+
+export default defineConfig({
+ plugins: [
+ remix({
+ unstable_ssr: false,
+ }),
+ ],
+});
+```
+
+### Development
+
+In SPA Mode, you develop the same way you would for a traditional Remix SSR app, and you actually use a running Remix dev server in order to enable HMR/HDR:
+
+```sh
+remix vite:dev
+```
+
+### Production
+
+When you build your app in SPA Mode, Remix will call the server handler for the `/` route and save the rendered HTML in an `index.html` file alongside your client side assets (by default `build/client/index.html`).
+
+```sh
+remix vite:build
+```
+
+To run your SPA, you serve your client assets directory via any HTTP server you wish, for example:
+
+```sh
+npx http-server build/client/
+```
+
+Or, if you are serving via an `express` server (although at that point you may want to consider just running Remix in SSR mode 😉):
+
+```js
+app.use("/assets", express.static("build/client/assets"));
+app.get("*", (req, res, next) =>
+ res.sendFile(
+ path.join(process.cwd(), "build/client/index.html"),
+ next
+ )
+);
+```
+
+## Notes/Caveats
+
+- You cannot use server APIs such as `headers`, `loader`, and `action` -- the build will throw an error if you export them
+
+- You can only export a `HydrateFallback` from your `root.tsx` in SPA Mode -- the build will throw an error if you export one from any other routes.
+
+- You cannot call `serverLoader`/`serverAction` from your `clientLoader`/`clientAction` methods since there is no running server -- those will throw a runtime error if called
+
+## Migrating from React Router
+
+We also expect SPA Mode to be useful in helping folks migrate existing React router apps over to Remix apps (SPA or not!).
+
+The first step towards this migration is getting your current React Router app running on `vite`, so that you've got whatever plugins you need for your non-JS code (i.e., CSS, SVG, etc.).
+
+**If you are currently using `BrowserRouter`**
+
+Once you're using vite, you should be able to drop your `BrowserRouter` app into a catch-all Remix route per the steps in the [this guide][migrating-rr].
+
+**If you are currently using `RouterProvider`**
+
+If you are currently using `RouterProvider`, then the best approach is to move your routes to individual files and load them via `route.lazy`:
+
+- Name these files according to the Remix file conventions to make the move to Remix (SPA) easier
+- Export your route components as a named `Component` export (for RR) and also a `default` export (for eventual use by Remix)
+
+Once you've got all your routes living in their own files, you can:
+
+- Move those files over into the Remix `app/` directory
+- Enable SPA Mode
+- Rename all `loader`/`action` function to `clientLoader`/`clientAction`
+- Add a `root.tsx` with a `default` export and a `HydrateFallback` - this replaces the `index.html` file from your React Router app
+
+[rfc]: https://github.com/remix-run/remix/discussions/7638
+[client-data]: ./client-data
+[2.5.0]: https://github.com/remix-run/remix/blob/main/CHANGELOG.md#v250
+[fetch]: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API
+[runtimes]: ../discussion/runtimes
+[kent-tweet]: https://twitter.com/kentcdodds/status/1743030378334708017
+[rr-setup]: https://reactrouter.com/en/main/start/tutorial#setup
+[routes-config]: ../file-conventions/remix-config#routes
+[route-lazy]: https://reactrouter.com/en/main/route/lazy
+[meta]: ../components/meta
+[links]: ../components/links
+[migrating-rr]: https://remix.run/docs/en/main/guides/migrating-react-router-app
diff --git a/integration/compiler-test.ts b/integration/compiler-test.ts
index dc70fa09be7..a9ff47aa2cf 100644
--- a/integration/compiler-test.ts
+++ b/integration/compiler-test.ts
@@ -252,7 +252,7 @@ test.describe("compiler", () => {
);
let routeModule = await fixture.getBrowserAsset(
- fixture.build.assets.routes["routes/built-ins"].module
+ fixture.build!.assets.routes["routes/built-ins"].module
);
// does not include `import bla from "node:path"` in the output bundle
expect(routeModule).not.toMatch(/from\s*"path/);
@@ -271,7 +271,7 @@ test.describe("compiler", () => {
);
let routeModule = await fixture.getBrowserAsset(
- fixture.build.assets.routes["routes/built-ins-polyfill"].module
+ fixture.build!.assets.routes["routes/built-ins-polyfill"].module
);
// does not include `import bla from "node:path"` in the output bundle
expect(routeModule).not.toMatch(/from\s*"path/);
diff --git a/integration/flat-routes-test.ts b/integration/flat-routes-test.ts
index 53eae110e1d..e45f4803e31 100644
--- a/integration/flat-routes-test.ts
+++ b/integration/flat-routes-test.ts
@@ -160,7 +160,7 @@ test.describe("flat routes", () => {
}
test("allows ignoredRouteFiles to be configured", async () => {
- let routeIds = Object.keys(fixture.build.routes);
+ let routeIds = Object.keys(fixture.build!.routes);
expect(routeIds).not.toContain(IGNORED_ROUTE);
});
diff --git a/integration/helpers/create-fixture.ts b/integration/helpers/create-fixture.ts
index 56317b192fd..99baf9396d4 100644
--- a/integration/helpers/create-fixture.ts
+++ b/integration/helpers/create-fixture.ts
@@ -51,6 +51,38 @@ export async function createFixture(init: FixtureInit, mode?: ServerMode) {
compiler === "vite" ? "build/server/index.js" : "build/index.js"
)
).href;
+
+ let getBrowserAsset = async (asset: string) => {
+ return fse.readFile(
+ path.join(projectDir, "public", asset.replace(/^\//, "")),
+ "utf8"
+ );
+ };
+
+ let isSpaMode =
+ compiler === "vite" &&
+ fse.existsSync(path.join(projectDir, "build/client/index.html"));
+
+ if (isSpaMode) {
+ return {
+ projectDir,
+ build: null,
+ isSpaMode,
+ compiler,
+ requestDocument: () => {
+ throw new Error("Cannot requestDocument in SPA Mode tests");
+ },
+ requestData: () => {
+ throw new Error("Cannot requestData in SPA Mode tests");
+ },
+ postDocument: () => {
+ throw new Error("Cannot postDocument in SPA Mode tests");
+ },
+ getBrowserAsset,
+ useRemixServe: init.useRemixServe,
+ };
+ }
+
let app: ServerBuild = await import(buildPath);
let handler = createRequestHandler(app, mode || ServerMode.Production);
@@ -89,16 +121,10 @@ export async function createFixture(init: FixtureInit, mode?: ServerMode) {
});
};
- let getBrowserAsset = async (asset: string) => {
- return fse.readFile(
- path.join(projectDir, "public", asset.replace(/^\//, "")),
- "utf8"
- );
- };
-
return {
projectDir,
build: app,
+ isSpaMode,
compiler,
requestDocument,
requestData,
@@ -175,6 +201,22 @@ export async function createAppFixture(fixture: Fixture, mode?: ServerMode) {
});
}
+ if (fixture.isSpaMode) {
+ return new Promise(async (accept) => {
+ let port = await getPort();
+ let app = express();
+ app.use(express.static(path.join(fixture.projectDir, "build/client")));
+ app.get("*", (_, res, next) =>
+ res.sendFile(
+ path.join(process.cwd(), "build/client/index.html"),
+ next
+ )
+ );
+ let server = app.listen(port);
+ accept({ stop: server.close.bind(server), port });
+ });
+ }
+
return new Promise(async (accept) => {
let port = await getPort();
let app = express();
@@ -281,7 +323,7 @@ export async function createFixtureProject(
at the same time, unless the \`remix.config.js\` file contains a reference
to the \`global.INJECTED_FIXTURE_REMIX_CONFIG\` placeholder so it can
accept the injected config values. Either move all config values into
- \`remix.config.js\` file, or spread the injected config,
+ \`remix.config.js\` file, or spread the injected config,
e.g. \`export default { ...global.INJECTED_FIXTURE_REMIX_CONFIG }\`.
`);
}
diff --git a/integration/spa-mode-test.ts b/integration/spa-mode-test.ts
new file mode 100644
index 00000000000..de003833b2b
--- /dev/null
+++ b/integration/spa-mode-test.ts
@@ -0,0 +1,362 @@
+import { test, expect } from "@playwright/test";
+
+import {
+ createAppFixture,
+ createFixture,
+ js,
+} from "./helpers/create-fixture.js";
+import type { Fixture, AppFixture } from "./helpers/create-fixture.js";
+import { PlaywrightFixture } from "./helpers/playwright-fixture.js";
+import { createProject, viteBuild } from "./helpers/vite.js";
+
+// SSR'd useId value we can assert against pre- and post-hydration
+const USE_ID_VALUE = ":R1:";
+
+test.describe("SPA Mode", () => {
+ let fixture: Fixture;
+ let appFixture: AppFixture;
+
+ test.beforeAll(async () => {
+ fixture = await createFixture({
+ compiler: "vite",
+ files: {
+ "vite.config.ts": js`
+ import { defineConfig } from "vite";
+ import { unstable_vitePlugin as remix } from "@remix-run/dev";
+
+ export default defineConfig({
+ plugins: [remix({ unstable_ssr: false })],
+ });
+ `,
+ "app/root.tsx": js`
+ import * as React from "react";
+ import { Form, Link, Links, Meta, Outlet, Scripts } from "@remix-run/react";
+
+ export default function Root() {
+ let id = React.useId();
+ return (
+
+
+
+
+
+
+