How toAdd runtime SSG and ISR to Remix

If you came to Remix from the Next.js world, you might wonder how to use SSG and ISR with Remix.

The framework doesn't support it; instead, the typical recommendation is to use HTTP cache to get similar behavior.

Let's see how we could use HTTP cache to implement SSG and ISR at runtime instead of build time.

Add Cache-Control headers

First, we need to add the Cache-Control header to the document responses.

Let's do that by changing our handleRequest function in entry.server

// help us create the string for the Cache-Control header
import { cacheHeader } from "pretty-cache-header";

let versionCookie = createCookie("version", {
  path: "/", // make sure the cookie we receive the request on every path
  secure: false, // enable this in prod
  httpOnly: true, // only for server-side usage
  maxAge: 60 * 60 * 24 * 365, // keep the cookie for a year
});

export default async function handleRequest(
  request: Request,
  responseStatusCode: number,
  responseHeaders: Headers,
  remixContext: EntryContext
) {
  await sleep(300); // delay response by 300ms to verify our cache is working

  let { version } = remixContext.manifest; // get the build version

  // if the response doesn't already have a cache-control header, add one
  if (!responseHeaders.has("cache-control")) {
    responseHeaders.append(
      "cache-control",
      cacheHeader({
        public: true, // cache on CDN
        private: false, // cache on browser
        maxAge: "60s", // cache time
        staleWhileRevalidate: "1y", // enables ISR
        staleIfError: "1y", // enables ISR
      })
    );
  }

  // Add new headers to the response
  responseHeaders.append("Vary", "Cookie");
  responseHeaders.append("Set-Cookie", await versionCookie.serialize(version));

  return isbot(request.headers.get("user-agent"))
    ? handleBotRequest(
        request,
        responseStatusCode,
        responseHeaders,
        remixContext
      )
    : handleBrowserRequest(
        request,
        responseStatusCode,
        responseHeaders,
        remixContext
      );
}

The Vary header tells the cache that if the Cookie header is different, it shouldn't use the same cache.

Then we set the Set-Cookie header to keep the build version.

With this, if we open the page in the browser, it will get cached. If we open it in a new tab, we shouldn't need to wait for the 300ms delay and instead get the HTML immediately.

Note: If you reload the page, the browser will use a no-cache policy and request the document again. To test this works, open a new tab, open the dev tools and then type the URL and enter.

Add API routes

Now, we can test our app to keep working, add an API route, for example, routes/api.time.ts and export a loader or action.

import type { DataFunctionArgs, SerializeFrom } from "@remix-run/node";
import { json } from "@remix-run/node";
import { useFetcher } from "@remix-run/react";
import { useEffect } from "react";

export function loader(_: DataFunctionArgs) {
  return json({ time: new Date().toISOString() });
}

// Note we use a named export and not a default one,
// We're implementing the Full Stack Components pattern by Kent C. Dodds
// Read more about this on https://www.epicweb.dev/full-stack-components
export function Time() {
  let { data, load } = useFetcher<SerializeFrom<typeof loader>>();

  useEffect(() => {
    load("/api/time");
  }, [load]);

  if (!data) return <div>Loading...</div>;
  return <div>{data.time}</div>;
}

Then, in another route of our app, consume it.

import { Time } from "./api.time";

export default function Component() {
  return <Time />;
}

Now, even if the user received a cached HTML, it will still fetch the API to get the current time.

Add public data to routes

A use case for SSG could be to generate routes with public data and fetch private data from the API client side.

Let's add some public data we want to cache to our route.

import type { DataFunctionArgs } from "@remix-run/node";

export async function loader(_: DataFunctionArgs) {
  // simulate slow request or DB query
  await new Promise((resolve) => setTimeout(resolve, 300));

  return json({
    articles: [
      { id: "1", title: "Hello World" },
      { id: "2", title: "Hello Remix" },
    ],
  });
}

Then, in our component, we can use the data.

import { useLoaderData } from "@remix-run/react";

export default function Component() {
  return (
    <>
      <Time />
      <ul>
        {loaderData.articles.map((article) => {
          return <li key={article.id}>{article.title}</li>;
        })}
      </ul>
    </>
  );
}

Now, the HTML will come with the articles already rendered, and if we open the page in a new tab, it will receive the HTML immediately without waiting.

But after we do a new build and the version change, it will wait until the browser cache expires to get the new HTML.