How toUse 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.