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 and can run on any modern runtime 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:
That's why we added support for SPA Mode in 2.5.0 (RFC), which builds heavily on top of the Client Data APIs.
SPA Mode is basically what you'd get if you had your own React Router + Vite setup using createBrowserRouter
/RouterProvider
, but along with some extra Remix goodies:
routes()
)route.lazy
<Link prefetch>
support to eagerly prefetch route modules<head>
management via Remix <Meta>
/<Links>
APIsSPA 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 APIs for data loading and mutations.
The index.html
is generated from the HydrateFallback
component in your root.tsx
route. 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.
You can get started quickly using the SPA Mode template in the repo:
npx create-remix@latest --template remix-run/remix/templates/spa
Or, you can manually opt-into SPA mode in your Remix+Vite app by setting ssr: false
in your Remix Vite plugin config:
// vite.config.ts
import { vitePlugin as remix } from "@remix-run/dev";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [
remix({
ssr: false,
}),
],
});
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:
npx remix vite:dev
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
).
npx remix vite:build
You can preview the production build locally with vite preview:
npx vite preview
vite preview
is not designed for use as a production server
To deploy, you can serve your app from any HTTP server of your choosing. The server should be configured to serve multiple paths from a single root /index.html
file (commonly called "SPA fallback"). Other steps may be required if the server doesn't directly support this functionality.
For a simple example, you could use sirv-cli:
npx sirv-cli build/client/ --single
Or, if you are serving via an express
server (although at that point you may want to consider just running Remix in SSR mode 😉):
app.use("/assets", express.static("build/client/assets"));
app.get("*", (req, res, next) =>
res.sendFile(
path.join(process.cwd(), "build/client/index.html"),
next
)
);
If you don't want to hydrate the full HTML document
, you can choose to use SPA mode and only hydrate a sub-section of the document such as <div id="app">
with a few minor changes.
1. Add an index.html
file
Since Remix won't render the HTML document, you will need to provide that HTML outside of Remix. The easiest way to do this is to just keep an app/index.html
document with a placeholder you can replace with the Remix rendered HTML at build time to generate the final index.html
<!DOCTYPE html>
<html lang="en">
<head>
<title>My Cool App!</title>
</head>
<body>
<div id="app"><!-- Remix SPA --></div>
</body>
</html>
The <!-- Remix SPA -->
HTML comment is what we'll replace with the Remix HTML.
div
, otherwise you will run into React hydration issues
2. Update root.tsx
Update your root route to render just the contents of <div id="app">
:
export function HydrateFallback() {
return (
<>
<p>Loading...</p>
<Scripts />
</>
);
}
export default function Component() {
return (
<>
<Outlet />
<Scripts />
</>
);
}
3. Update entry.server.tsx
In your app/entry.server.tsx
file, you'll want to take the Remix-rendered HTML and insert it into your static app/index.html
file placeholder. You'll also want to stop pre-pending the <!DOCTYPE html>
declaration like the default entry.server.tsx
file does since that should be in your app/index.html
file).
import fs from "node:fs";
import path from "node:path";
import type { EntryContext } from "@remix-run/node";
import { RemixServer } from "@remix-run/react";
import { renderToString } from "react-dom/server";
export default function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
) {
const shellHtml = fs
.readFileSync(
path.join(process.cwd(), "app/index.html")
)
.toString();
const appHtml = renderToString(
<RemixServer context={remixContext} url={request.url} />
);
const html = shellHtml.replace(
"<!-- Remix SPA -->",
appHtml
);
return new Response(html, {
headers: { "Content-Type": "text/html" },
status: responseStatusCode,
});
}
npx remix reveal
if you don't currently have an app/entry.server.tsx
file in your app
4. Update entry.client.tsx
Update app/entry.client.tsx
to hydrate the <div id="app">
instead of the document:
import { RemixBrowser } from "@remix-run/react";
import { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";
startTransition(() => {
hydrateRoot(
document.querySelector("#app"),
<StrictMode>
<RemixBrowser />
</StrictMode>
);
});
npx remix reveal
if you don't currently have an app/entry.client.tsx
file in your app
SPA Mode only works when using Vite and the Remix Vite plugin
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
It's important to note that Remix SPA mode generates your index.html
file by performing a "pre-render" of your root route on the server during the build
document
, window
, localStorage
, etc.entry.client.tsx
so they don't end up in the server buildReact.lazy
or the <ClientOnly>
component from remix-utils
If you are running into ESM/CJS issues with your app dependencies you may need to play with the Vite ssr.noExternal option to include certain dependencies in your server bundle:
import { vitePlugin as remix } from "@remix-run/dev";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
plugins: [
remix({
ssr: false,
}),
tsconfigPaths(),
],
ssr: {
// Bundle `problematic-dependency` into the server build
noExternal: ["problematic-dependency"],
},
// ...
});
These issues are usually due to dependencies whose published code is incorrectly-configured for CJS/ESM. By including the specific dependency in ssr.noExternal
, Vite will bundle the dependency into the server build and can help avoid runtime import issues when running your server.
If you have the opposite use-case and you specifically want to keep dependencies external to the bundle, you can use the opposite ssr.external
option.
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.
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
:
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:
app/
directoryloader
/action
function to clientLoader
/clientAction
index.html
file with an app/root.tsx
route that exports a default
component and HydrateFallback