How to do ISR and advanced caching with Remix

by Matt Kane

#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 supports ISR using a CDN cache. Remix controls how the pages are cached at the CDN using HTTP 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 ☝️
});

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.

#Optimizing: durable and stale-while-revalidate

What if your page is slow to render? Perhaps it needs to load data from a slow API or database, plus running a serverless function to render a page always involves some extra time vs. serving from cache. Fortunately, two extra cache directives come in really useful here.

First, let’s enable Nelify’s Durable Cache using the durable directive. Without it, each edge node only has its own local cache to consult before having to invoke a serverless function to generate the page. By specifying durable, an additional cache layer is enabled to cache responses from serverless function. This layer is used by all edge nodes globally.

Durable Cache not yet supported for Edge Function responses

Remix SSR on Netlify can run on either Netlify Functions or Netlify Edge Functions. As of October 2024, the durable directive is not supported for Edge Function SSR responses.

Second, let’s add a specific optimization for pages that need revalidation: the stale-while-revalidate directive. This does what it says on the box: if the cached page has expired, the edge node will send a stale version while it revalidates the page in the background. As result, the page load is never blocked for the user. Of course, you control exactly how much “staleness” is ok for the page.

We can add these directives to the Cache-Control header, but there is a potential risk here:

While durable is a Netlify-specific optimization which is ignored by any other software, the stale-while-revalidate directive might also affect caching in the browser, so you may end up with conflicting versions or double-revalidations taking place. Luckily, there is a standard solution for this: the CDN-Cache-Control header, which is used only by CDN providers and never by browsers. And since some customers have a less-common setup where Netlify is running behind another CDN (typically due to a gradual migration process), any further caching conflicts between these can be avoided by using the Netlify-specific header Netlify-CDN-Cache-Control, as we’ll do in the example below.

Using the CDN-only header also helps solve another caching issue. When a new version of a site is deployed, Netlify’s CDN automatically invalidates all related files, but how would the browser know? As long as it uses cached pages, it might ask for additional assets that either don’t exist anymore, or do exist but have deprecated contents. The solution lies in separating concerns between browser and CDN:

  • The browser only uses the regular Cache-Control header, so we’ll change its value to ensure that the browser always checks the freshness of its cache against the server. Thanks to conditional requests, only content that has actually changed would be downloaded.

  • Netlify would handle the actual caching logic for your page, including all revalidation cases.

Here’s an example:

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, durable, s-maxage=300, stale-while-revalidate=604800",
});

#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);
};

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;

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}`,
},
});
};

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}`,
},
});
};

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}`,
},
}
);
};

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

Parent loader headers are not merged

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}`);
}

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",
});

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",
});

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>;
}

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.

No caching in development

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

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.