#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:
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:
#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.
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.
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:
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.
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:
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.
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:
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:
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:
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:
Or if you prefer, you can also deploy an example directly to Netlify from here by clicking the button below.
For more details, and to see how to migrate an existing site, see the Remix on Netlify docs.