Use Remix as a SPA only
Remix always does SSR on document requests. Then it works as an MPA until JS loads and React hydrates your app. At that point, it starts working as a SPA.
But you could go to full SPA mode. Let's see how.
Note: This is more an experiment than a recommended way to use Remix. If you want Remix as only SPA, use React Router instead.
Once you create a new Remix app, you will have an app/root
file like this.
import type { MetaFunction } from "@remix-run/node";
import {
Links,
LiveReload,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from "@remix-run/react";
export const meta: MetaFunction = () => ({
charset: "utf-8",
title: "New Remix App",
viewport: "width=device-width,initial-scale=1",
});
export default function App() {
return (
<html lang="en">
<head>
<Meta />
<Links />
</head>
<body>
<Outlet />
<ScrollRestoration />
<Scripts />
<LiveReload />
</body>
</html>
);
}
The Outlet
component used there is where our routes will render. In our case, we need to prevent the rendering server-side because we want a SPA-only mode, so let's install Remix Utils.
npm add remix-utils
Then we can wrap the Outlet
component in the Remix Util's ClientOnly
component.
import type { MetaFunction } from "@remix-run/node";
import {
Links,
LiveReload,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from "@remix-run/react";
import { ClientOnly } from "remix-utils";
export const meta: MetaFunction = () => ({
charset: "utf-8",
title: "New Remix App",
viewport: "width=device-width,initial-scale=1",
});
export default function App() {
return (
<html lang="en">
<head>
<Meta />
<Links />
</head>
<body>
<ClientOnly>
<Outlet />
</ClientOnly>
<ScrollRestoration />
<Scripts />
<LiveReload />
</body>
</html>
);
}
With this change, you'll notice that your app now renders empty on a document request, and once JS hydrates, it renders the actual UI.
Let's add a generic skeleton UI to make it look better.
import type { MetaFunction } from "@remix-run/node";
import {
Links,
LiveReload,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from "@remix-run/react";
import { ClientOnly } from "remix-utils";
export const meta: MetaFunction = () => ({
charset: "utf-8",
title: "New Remix App",
viewport: "width=device-width,initial-scale=1",
});
export default function App() {
return (
<html lang="en">
<head>
<Meta />
<Links />
</head>
<body>
<ClientOnly fallback={<Skeleton />}>
<Outlet />
</ClientOnly>
<ScrollRestoration />
<Scripts />
<LiveReload />
</body>
</html>
);
}
function Skeleton() {
// here, create a skeleton UI for your app
}
Now, let's ensure you send the HTML as if it were static. We will use Cache-Control in the app/entry.server
file.
import { PassThrough } from "stream";
import type { EntryContext } from "@remix-run/node";
import { Response } from "@remix-run/node";
import { RemixServer } from "@remix-run/react";
import isbot from "isbot";
import { renderToPipeableStream } from "react-dom/server";
// install this to help you generate Cache-Control strings
import { cacheHeader } from "pretty-cache-header";
const ABORT_DELAY = 5000;
export default function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
) {
// Add the Cache-Control header
request.headers.set(
"Cache-Control",
cacheHeader({
public: true,
maxAge: "1day",
staleWhileRevalidate: "1year",
})
);
return isbot(request.headers.get("user-agent"))
? handleBotRequest(
request,
responseStatusCode,
responseHeaders,
remixContext
)
: handleBrowserRequest(
request,
responseStatusCode,
responseHeaders,
remixContext
);
}
function handleBotRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
) {
//omitted for brevity. You can see the complete code in the default entry.server
}
function handleBrowserRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
) {
// omitted for brevity. You can see the complete code in the default entry.server
}
Now, you can deploy your app and enjoy your SPA-only Remix app. To do it more thoroughly, avoid using UI route-level loader
and action
functions, and instead, use the useFetcher
hook to trigger the fetch 100% from the browser.