How to do ISR and advanced caching with Remix and Netlify

Matt Kane - Feb 14 - - Dev Community

TL;DR

ISR is a powerful pattern for rendering pages on the web. You can use it in any framework that supports SSR, but Remix gives you some tools that make it particularly well suited, and it does it all using standard HTTP headers. You don't need anything proprietary to do this as long as your CDN supports the right primitives. You can then take this further with more advanced caching tools, giving you the best of both worlds: a fast, cached site that's always fresh.

What is ISR?

Incremental Static Regeneration or ISR is an approach to page rendering that lets you cache a page when first requested, and then control when that page is regenerated - either after a specific period, or by manually revalidating it. It's great for pages that don't change very often, or which don't need to be absolutely fresh when they are requested but which may still change between deploys. It was popularized by Next.js, which implements it internally within the framework, but it's a pattern that can be implemented with any framework in a standards-based way using HTTP headers with a CDN that supports them.

How to implement ISR using Remix

Remix uses native HTTP caching for ISR, where the pages are cached at the CDN level using Cache-Control headers. The team behind Remix built it with the philosophy of "use the platform" – meaning that whenever possible, you should use the tools that the web gives you rather than inventing something proprietary.

Netlify has a powerful set of caching features that let you do any kind of ISR on Remix. This guide will show the general principles of ISR and advanced caching with Remix, as well as the details of implementing them with Netlify's edge network.

Caching on Remix

To cache a Remix page, you use the headers function, which lets you define headers at the route level. Because Remix uses nested routes, it uses the headers returned by the deepest route, so if you want to define routes at different levels you need to make sure you merge them. Here is a basic headers function, with no merging:

export const headers: HeadersFunction = () => ({
  "Cache-Control": "public, max-age=300, s-maxage=3600",
  // Sets the cache time for browser ☝️ and CDN ☝️
});
Enter fullscreen mode Exit fullscreen mode

This caches the reponse in the browser (max-age) for 5 minutes, and in the CDN (s-maxage) for 1 hour.

Careful!

Notice the difference in the use of a dash in max-age, vs no dash in s-maxage. This inconsistency in the standard is a source of much pain!

This is basic caching: if a user loads the page within an hour they will get the cached response, but if it's more than an hour then it will block while it re-renders.

If the data is unlikely to change, you can set a long expiry time. Don't worry if you find you need to change it later – any new deploy will clear the CDN cache instantly and your visitors will get the fresh content, without broken asset links. It's important that when you do this it's only for the CDN cache, not the browser cache, because otherwise your users may be left with stale content in their cache and you would have no way to invalidate it.

Using stale-while-revalidate for faster loading

But what if your page is slow to render? Maybe it needs to load data from a slow API or database. You can still get fast loading for revalidated pages though using the stale-while-revalidate directive. This does what it says: if the cached page has expired, then it will send a stale version while it revalidates the page in the background. The page load is never blocked for the user, though it won't be perfectly fresh for everyone.

We can add the directive to the Cache-Control header, but there is a risk here because this will also be cached in the browser, so you may end up with conflicting versions or double-revalidations trying to take place. Luckily there is a workaround for this which is the CDN-Cache-Control header. This is not used by the browser, only by the CDN, so there's no risk of conflicting directives. But what if there's another CDN cache that sees this header? This won't apply in most cases unless you're running one CDN in front of another, we can remove the risk entirely by using a CDN-specific cache header. Netlify uses the Netlify-CDN-Cache-Control header, which has the same semantics as normal Cache-Control headers, but is ignored by the browser and other CDNs, so there's no risk of it caching in the wrong place.

Once we are using the CDN-specific header, we can change the regular Cache-Control header to ensure it always checks the freshness of its cache. This doesn't mean it will download it every time, because the browser will do a conditional request that ensures it only downloads it if it has changed. The reason we need to do this is if for example you have deployed a new version of the site, which will mean the CDN cache is invalidated. If your browser cache has an old version of the page, it will be requesting assets with hashed filenames that no longer exist. Worse, it may be requesting assets that do exist, but are not the ones you want. This is why it's important to always check the freshness of the cache in this scenario.

Here's how to use it:

export const headers: HeadersFunction = () => ({
  // Tell the browser to always check the freshness of the cache
  "Cache-Control": "public, max-age=0, must-revalidate",
  // Tell the CDN to treat it as fresh for 5 minutes, but for a week after that return a stale version while it revalidates
  "Netlify-CDN-Cache-Control": "public, s-maxage=300, stale-while-revalidate=604800",
});
Enter fullscreen mode Exit fullscreen mode

This header says that the page is stale after 5 minutes, but tells the CDN to return the stale response and regenerate it in the background, unless it's over a week old. A popular page will always be fresh, but a rarely-visited one will not keep re-rendering.

On-demand revalidation with cache tags

While max age and stale-while-revaidate are a nice and easy way to implement ISR on Remix, we're not really using the power of Remix and the CDN. Up until now we've just been using a headers function, but Remix loaders can also return headers. This is a powerful pattern, because it keeps the cache logic next to the thing that probably drives it - the data. Handling caching in the loader gives us the power to scope our revalidations to exactly the pages that use the data that may have changed. This is because Netlify supports the Cache-Tag header, with fine-grained revalidation by tag. Netlify also supports the Netlify-Cache-Tag header with the same meaning but specific to just Netlify's CDN, and which will not be visible to the browser.

To see how cache tags work, let's look at an example. Imagine you are creating a store. You will have product pages, listings and categories. On the listing page you need to fetch all products, in the category page you need the products in one category, and on the product page you just need one product's data. Latency is everything in e-commerce, so you want to cache your pages as efficiently as possible. For this reason you will use advanced caching on Netlify, with on-demand revalidation. When a product is edited, you only want to invalidate routes that use that data, and keep everything else cached for a long period. Let's look at a simplified loader for the listing route.

export const loader = async () => {
  const products = await myStore.products.findMany();
  return json(products);
};
Enter fullscreen mode Exit fullscreen mode

Now let's add some cache tags. Remix lets you return a Response object from a loader, or you can use the json helper to simplify it, passing the headers in the second argument.

export const loader = async () => {
  const products = await myStore.products.findMany();
  return json(products, {
    headers: {
      // Always revalidate in the browser
      "Cache-Control": "public, max-age=0, must-revalidate",
      // Cache for a year in the CDN
      "Netlify-CDN-Cache-Control": "public, s-maxage=31536000",
      // Purge from the cache whenever the products change
      "Cache-Tag": "products",
    },
  });
};

// Just return the loader headers. Merging logic can go here if you need it.
export const headers: HeadersFunction = ({ loaderHeaders }) => loaderHeaders;
Enter fullscreen mode Exit fullscreen mode

By adding the cache tag "products", we can revalidate the listings page whenever the products change. The tag name has no semantic meaning for the CDN, but choosing the model name makes it more readable for you.

We'll get onto the detail of the actual revalidation in a bit. First let's look at how we can take this further. For our category pages we probably don't need to invalidate all of them if only one product has changed. We can handle this with cache tags too. Here's a loader for the category page:

export const loader = async ({ params }) => {
  const products = await myStore.products.findMany({
    where: {
      "category": params.category,
    },
  });

  return json(products, {
    headers: {
      "Cache-Control": "public, max-age=0, must-revalidate",
      "Netlify-CDN-Cache-Control": "public, s-maxage=31536000",
      // Tag with the category id
      "Cache-Tag": `products:category:${params.category}`,
    },
  });
};
Enter fullscreen mode Exit fullscreen mode

This lets us revalidate the route when only products in that category have changed.

We can be even more granular for the product page. Here we're loading just one product, and tagging it with just that product's id.

export const loader = async ({ params }) => {
  const product = await myStore.products.get(params.id);

  return json(product, {
    headers: {
      "Cache-Control": "public, max-age=0, must-revalidate",
      "Netlify-CDN-Cache-Control": "public, s-maxage=31536000",
      // Tag with the product id
      "Cache-Tag": `products:id:${product.id}`,
    },
  });
};
Enter fullscreen mode Exit fullscreen mode

The cache tags we're using here can be any string. I've chosen this scheme of separating the model, field and id with colons, but you can use whatever scheme you want. You can also send more than one tag. Just separate them with commas:

export const loader = async ({ params }) => {
  const product = await myStore.products.get(params.id);
  const offers = await myStore.offers.findMany();

  return json(
    { product, offers },
    {
      headers: {
        "Cache-Control": "public, max-age=0, must-revalidate",
        "Netlify-CDN-Cache-Control": "public, s-maxage=31536000",
        // Tag with the product id and all offers
        "Cache-Tag": `offers,products:id:${product.id}`,
      },
    }
  );
};
Enter fullscreen mode Exit fullscreen mode

This says that this route should be revalidated when either that one product is updated, or if any of the offers change.

Remember!

Remix always uses the headers from the deepest route, so if you're using loaders in parent routes, make sure to merge them. See the docs for headers for more details.

How to revalidate your pages on demand

Now you're returning your pages with all the right cache headers, you need to revalidate them when the data actually changes. Netlify gives you two ways to do this. You can either hit the API directly, using a Netlify access token, or you can use a helper inside a serverless function. I'm going to show you how to do on-demand revalidation inside a Remix route action, but you could also do this in a resource route that's called by something like a CMS webhook.

Imagine this is on your admin page where you can edit the products. When the store owner edits a product or adds a new one, you want to invalidate any pages that use that data. We can do this using the purgeCache helper from the @netlify/functions library.

import { purgeCache } from "@netlify/functions";

export async function action({ request }: ActionFunctionArgs) {
  const body = await request.formData();
  const product = await myStore.products.update({
    where: {
      id: body.get("id"),
    },
    data: {
      title: body.get("title"),
    },
  });
  await purgeCache({
    tags: ["products", `product:id:${product.id}`, `products:category:${product.category}`],
  });
  return redirect(`/products/${product.slug}`);
}
Enter fullscreen mode Exit fullscreen mode

You can see that in this case we're purging the products tag, which will revalidate the listings page, the specific category for that product (which will purge that category), and finally the tag for that individual product.

The beauty of this approach over purging by route is that you don't need to keep track of which routes need which data – each route can just return the cache tag header matching the data they use.

Fine-grained cache control

So far we've been caching each page as the same for each visitor. This is probably what you want for most public pages, but not always. The Vary header can be used to store different versions of a page according to the request headers. However this can be quite an inflexible instrument, so Netlify extended this with the Netlify-Vary header. A common case will be to control how query parameters are treated. The strict spec-compliant way that a cache will handle these is to treat the whole URL complete with query as the cache key, meaning that ?foo=bar and ?foo=baz will be treated as different pages. While this is what you want in some cases, there are a lot of times when this will break caching entirely. A very common example for this is social sharing. Links from social media will often have tracking parameters added automatically, and you don't want to cache a different copy of the page for each visitor. This is also the case if you are using ads with tracking IDs. Should ?fbclid=nnnn... be stored as a separate page than ?fbclid=mmmm...? Probably not. However you might be using the query param for something that you do want to use for the cache. A common example might be something like sorting, filtering or pagination. Netlify lets you control exactly which query params are used for the cache key using the Netlify-Vary header. Here's how you can use it:

export const headers: HeadersFunction = () => ({
  "Cache-Control": "public, max-age=0, must-revalidate",
  "Netlify-CDN-Cache-Control": "public, s-maxage=300, stale-while-revalidate=604800",
  "Netlify-Vary": "query=sort|page|per_page",
});
Enter fullscreen mode Exit fullscreen mode

In this example it will treat pages with different values for sort, page and per_page as different pages, but will ignore any other query params. This is a powerful tool for controlling your cache, and can be used in combination with cache tags for even finer control.

Cookies and caching

If there are user preferences that change how a page is displayed they needn't break caching. You probably shouldn't vary on the whole Cookie header, because this will mean that every user need their own version of the page cached, but you can use the Netlify-Vary header to control exactly which cookies are used for the cache key. Here's an example using a cookie to control the theme:

export const headers: HeadersFunction = () => ({
  "Cache-Control": "public, max-age=0, must-revalidate",
  "Netlify-CDN-Cache-Control": "public, s-maxage=300, stale-while-revalidate=604800",
  // Cache each theme separately
  "Netlify-Vary": "cookie=theme",
});
Enter fullscreen mode Exit fullscreen mode

You can use this to choose cookies with low cardinality (i.e. ones that have a small number of possible values) to vary the cache on, which means you can have customized pages while still caching them. If there is a light theme and a dark theme, each of these will then be cached separately.

i18n and caching

If you have an internationalized site, you might want to cache different versions of the same page for different browser languages. If you are using different routes for different languages then there's nothing to worry about, but what if you are detecting the language from the user's request headers? You can use the Netlify-Vary header to control this, with the language directive. Here's an example using react-i18next:

import { useTranslation } from "react-i18next";

export const headers: HeadersFunction = () => ({
  "Cache-Control": "public, max-age=0, must-revalidate",
  "Netlify-CDN-Cache-Control": "public, s-maxage=300, stale-while-revalidate=604800",
  // Cache each language separately
  "Netlify-Vary": "language",
});

export let handle = { i18n: "home" };

export default function Component() {
  let { t } = useTranslation("home");
  return <h1>{t("title")}</h1>;
}
Enter fullscreen mode Exit fullscreen mode

You can combine this with cookie-based language settings, by adding a cookie directive to the Netlify-Vary header.

Debugging your cache

Advanced cache tools like this can be tough to debug. The Cache-Status header can help, by showing whether the CDN had a had a hit, miss or stale response. Netlify supports this header, and you can use it to see what's happening with your cache.

Remember!

When running netlify dev it will strip all CDN cache headers, but the cache itself is not used. You need to deploy your site to see the cache in action.

Remix gives you powerful tools to control your cache, when combined with a platform like Netlify that supports advanced caching features. This gives you the power to control your cache in a way that's standards-based and uses the web platform.

Get started

If you'd like to see a real example, take a look at this contacts demo, which is based on the main Remix tutorial, but with the addition of advanced cache control.

To create your own Remix site to deploy to Netlify, you can get started with this command:

npx create-remix@latest --template netlify/remix-template
Enter fullscreen mode Exit fullscreen mode

Or if you prefer, you can also deploy an example directly to Netlify from here by clicking the button below.

Deploy to Netlify

For more details, and to see how to migrate an existing site, see the Remix on Netlify docs.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .