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.