Published on December 20, 2023

How to Implement Incremental Static Regeneration (ISR) in Astro

How to Implement Incremental Static Regeneration (ISR) in Astro

Every so often, a web framework emerges that just clicks with your soul. For me, that framework is Astro. It feels like it was built for me, and working with it feels like a breath of fresh air—a blend of performance and developer experience that's hard to find in the JavaScript ecosystem.

Yet, for all the joy Astro has brought me, there's one thing I've been really missing: the ability to incrementally regenerate static pages on the fly. This feature, commonly known as Incremental Static Regeneration (ISR), has become somewhat of a staple in modern web development frameworks.

In this post, I'm going to walk you through how I've managed to implement this feature in Astro and how you can do the same in your projects.

Understanding Incremental Static Regeneration (ISR)

Before we dive into the implementation, let's first take a moment to understand what ISR is and why it's so important. In traditional static site generation, your entire site is built at once, which can be time-consuming for large websites. ISR, on the other hand, allows developers to regenerate static pages on demand, after the initial build. This means that you can update individual pages without rebuilding the whole site, leading to faster build times and the ability to reflect content updates more quickly.

ISR is particularly useful for sites with a large number of pages or for those that update content frequently, such as e-commerce sites, blogs, and news platforms. It strikes a balance between the benefits of static generation (speed, security, and reliability) and the flexibility of server-side rendering (fresh content and dynamic updates).

So, Why Choose ISR?

  • Large Sites: If your site has thousands of pages, ISR can significantly reduce build times compared to SSG.
  • Infrequent Content Updates: For content that changes less often than every five minutes on average, ISR is ideal.
  • High Traffic: When your site has many visitors, it's inefficient to render the same page for each one. ISR solves this by caching pages.
  • Slow CMS/Database: If your backend is slow or charges per request, ISR can reduce the frequency of those requests.

When ISR Might Not Be for You

  • Content Inside Your Project: If your content is part of your project, like markdown files, ISR might not be necessary.
  • Avoiding Server Management: If you prefer not to run a server or use serverless platforms, you might want to skip ISR.
  • Logged-in Experiences: When each user sees a slightly different version of a page, caching might not work.
  • Frequent Layout/Styling Changes: If you often change your site's structure, you'll need full rebuilds, which makes ISR less useful.

How We're Going to Implement ISR in Astro

To implement ISR, all we want to do is cache the rendered content of each page for reuse in future requests for some predefined period.

There are a few different ways to implement ISR in Astro. For example, it's possible to rely on our CDN to cache individual pages using a Cache-Control header. This works particularly well for sites that are hosted on a serverless platform and don't have access to a local and persistent cache storage.

Another approach is to use a local cache storage, such as Redis, to store the rendered content of each page. This approach is a bit more involved, but it's also more flexible and allows us to do some cool things like cache invalidation and background regeneration.

In this blog post, I will show you both approaches but will be focusing more on the latter.

Setting Up Our Project

To get started with implementing ISR in Astro, you'll first need to ensure that your Astro project is set up correctly. If you haven't already created an Astro project, you can do so by running the following command:

npm create astro@latest

Once you've created your project, you'll need to create at least one page with some dynamic content. For this example, we're going to create a simple page that displays the current time.

  1. Create a new file in the src/pages directory of your Astro project. You can name it time.astro or whatever you like.

  2. Inside the time.astro file, you can write a simple component that outputs the current time. Here's an example:

---
const currentTime = new Date().toLocaleTimeString();
---

<html>
<head>
<title>Current Time</title>
</head>
<body>
<h1>The current time is: {currentTime}</h1>
</body>
</html>

This page will get the current time and display it in the browser. If you run npm run dev and navigate to http://localhost:4321/time, you should see something like this:

The current time is: 4:20:00 PM

The page will default to SSR mode, which means that the page will be rendered on the server and then sent to the browser. Now, let's see how we can implement ISR to cache the rendered content of this page so we don't have to re-render it on every request.

Implementing ISR with Cache-Control Headers

The first approach we're going to look at is using CDN caching to cache the rendered content of our page. As I mentioned previously, this approach is particularly useful for sites that are hosted on a serverless platform and don't have access to a local and persistent cache storage. This method takes advantage of the Cache-Control header to cache the rendered content of our page.

Understanding the Cache-Control Headers

Before we implement ISR, it's crucial to understand Cache-Control headers, as they are the key to controlling CDN caching behavior. Cache-Control headers are used to define how, and for how long, the responses should be cached by browsers and CDNs. Here's a breakdown of some common Cache-Control directives:

  • max-age=<seconds>: Specifies the maximum amount of time a resource will be considered fresh. This applies to both shared caches (like CDNs) and private client-side caches (like browsers).
  • s-maxage=<seconds>: Similar to max-age, but it only applies to shared caches. If s-maxage is present, CDNs will use it instead of max-age.
  • public: Marks the response as cacheable by both shared caches and private client-side caches.
  • private: Indicates the response is intended for a single user and should not be stored by shared caches.
  • no-cache: Forces caches to submit the request to the origin server for validation before releasing a cached copy, every time.
  • no-store: The cache should not store anything about the client request or server response.

Setting Up Cache-Control Headers

To implement ISR on your Astro page using CDN caching, you need to set the appropriate Cache-Control headers. Here's how you can do it:

  1. Modify your time.astro page to include the Cache-Control header:
---
const currentTime = new Date().toLocaleTimeString();

// Set Cache-Control headers for CDN caching
Astro.response.headers.set('Cache-Control', 'public, s-maxage=60, stale-while-revalidate=30');
---

<html>
<head>
<title>Current Time</title>
</head>
<body>
<h1>The current time is: {currentTime}</h1>
</body>
</html>

In this example, s-maxage=60 tells the CDN to cache the page for 60 seconds. stale-while-revalidate=30 allows the CDN to serve a stale version of the page for an additional 30 seconds while it fetches a fresh one in the background.

  1. Deploy your site to a hosting provider that supports CDN caching with Cache-Control headers, such as Vercel, Netlify, or Cloudflare.

  2. Once deployed, your Astro page will be served from the CDN cache. The CDN will automatically regenerate the page after the specified s-maxage period has passed, ensuring that the content stays up to date.

Implementing ISR with In-Memory Caching

Alright, now let's look at how we can implement ISR using an in-memory cache within our Node.js server. This approach is useful if you don't have a CDN, or if you want more granular control over the caching behavior compared to what we can achieve with CDN caching.

For this approach, we will be taking advantage of Astro's middleware feature to intercept each request and either serve the cached response or regenerate the page if it's stale.

Setting up the Middleware

First, we'll create a middleware function in Astro that will intercept each request. To do so, create a new file called middleware.ts in the root of your Astro project and add the following code:

import type { MiddlewareHandler } from "astro";

export const onRequest: MiddlewareHandler = async (req, next) => {
console.log("[Middleware] onRequest", req.url.pathname);
return await next();
}

This middleware function will be called for every request that comes into our Astro server. It will log the request URL to the console and then call the next() function to continue processing the request.

If you run npm run dev and navigate to http://localhost:4321/time, you should see the following output in your console:

[Middleware] onRequest /time

Caching the Rendered Content

Now that we have our middleware set up to intercept requests, we can implement the in-memory caching logic. We'll use a simple JavaScript object to store our cached responses, keyed by the request URL. For a more robust solution, you might consider using a dedicated in-memory data store like Redis, but for the sake of this example, we'll keep it simple.

Let's start with a simple implementation of our cache. We will later add some additional logic to make it more flexible and robust.

import type { MiddlewareHandler } from "astro";

// Define cache types
type Path = string;
interface ICachedResponse {
response: Response;
}

// Initialize an in-memory cache using a JavaScript Map.
// It will store the path as the key and the cached response as the value.
const cache = new Map<Path, ICachedResponse>();

export const onRequest: MiddlewareHandler = async (req, next) => {
console.log("[Middleware] onRequest", req.url.pathname);

// Attempt to retrieve a cached response for the current request path.
const cached = cache.get(req.url.pathname);

// If a cached response exists, return a clone of the response.
if (cached) return cached.response.clone();

// If there is no cached response, continue processing the request.
const response = await next();

// Cache the new response by cloning it and storing it in the cache.
cache.set(req.url.pathname, { response: response.clone() });

// Return the original response to the client.
return response;
}

Now, if we run npm run dev and navigate to http://localhost:4321/time, we should keep seeing the same output every time we refresh the page. This is because the middleware is caching the response and serving it from the cache on subsequent requests.

Adding Cache Invalidation

Now that we have our caching logic in place, we can add some additional logic to invalidate the cache after a certain period. This will ensure that the content stays up-to-date and that we don't serve stale content to our users.

To do so, we'll add an expires property to our cached response object. This property will be set to the current time plus the number of seconds we want to cache the response for. We'll also add some logic to check if the cached response has expired before serving it to the client.

import type { MiddlewareHandler } from "astro";

type Path = string;
interface ICachedResponse {
response: Response;
// The time when the cached response will expire.
expires: number;
}

const cache = new Map<Path, ICachedResponse>();

export const onRequest: MiddlewareHandler = async (req, next) => {
console.log("[Middleware] onRequest", req.url.pathname);

const ttl = 60; // 1 minute
const cached = cache.get(req.url.pathname);

if (cached && cached.expires > Date.now()) {
// If the cached response is still valid, return it.
return cached.response.clone();
} else if (cached) {
// If the cached response has expired, delete it from the cache.
cache.delete(req.url.pathname);
}

const response = await next();

cache.set(req.url.pathname, {
response: response.clone(),
// Set the expiration time to 1 minute from now.
expires: Date.now() + ttl * 1000
});

return response;
}

Now, if we run npm run dev and navigate to http://localhost:4321/time, we should see the same output every time we refresh the page. However, if we wait for 1 minute and refresh the page again, we should see a new output. This is because the cached response has expired and the middleware has regenerated the page.

This is a good start, but the issue is that the middleware is now aggressively caching the response for every page. However, we usually only want to cache pages that are expensive to render, and at the same time, we may want to change the duration of the cache for each page.

Adding Page-Specific Cache Control

To solve this issue, we can take advantage of Astro's locals feature. First, let's define a new function for our locals object that will allow us to set the cache duration for each page.

Head to src/env.d.ts and add the following code:

/// <reference types="astro/client" />

namespace App {
interface Locals {
// This will allow us to set the cache duration for each page.
cache(seconds: number): void;
}
}

This will expose a new cache() function on the locals object that we can use to define the cache duration for each page. Now, let's update our middleware to use this new function.

import type { MiddlewareHandler } from "astro";

type Path = string;
interface ICachedResponse {
response: Response;
expires: number;
}

const cache = new Map<Path, ICachedResponse>();

export const onRequest: MiddlewareHandler = async (req, next) => {
console.log("[Middleware] onRequest", req.url.pathname);

let ttl: number | undefined;
// Add a `cache` method to the `req.locals` object
// that will allow us to set the cache duration for each page.
req.locals.cache = (seconds: number = 60) => { ttl = seconds; };

const cached = cache.get(req.url.pathname);

if (cached && cached.expires > Date.now()) {
return cached.response.clone();
} else if (cached) {
cache.delete(req.url.pathname);
}

const response = await next();

// If the `cache` method was called, store the response in the cache.
if (ttl !== undefined) {
cache.set(req.url.pathname, {
response: response.clone(),
expires: Date.now() + ttl * 1000
});
}

return response;
}

We've updated the middleware to add a cache() method to the req.locals object. This method will allow us to set the cache duration for each page. We've also made the ttl variable dynamic, so that unless the cache() method is called, the middleware will not cache the response. This will allow us to control the caching behavior for each page.

If you refresh the http://localhost:4321/time page, you should see a new output every time. This is because we haven't called the cache() method yet, so the middleware is not caching the response.

Setting the Cache Duration for Each Page

Now that we have our middleware set up to allow us to set the cache duration for each page, let's update our time.astro page to take advantage of this new feature.

---
// Set the cache duration for this page to 1 minute.
Astro.locals.cache(60);
const currentTime = new Date().toLocaleTimeString();
---

<html>
<head>
<title>Current Time</title>
</head>
<body>
<h1>The current time is: {currentTime}</h1>
</body>
</html>

Finally, if you refresh the http://localhost:4321/time page, you should see the same output every time for 1 minute. This is because we've set the cache duration for this page to 1 minute.

Considerations for Production

While an in-memory cache works well for demonstration purposes, it has limitations in a production environment. Since the cache is stored in the memory of a single process, it won't be shared across multiple instances of your application, which is common in production deployments.

For a scalable production-ready caching solution, you would typically use a distributed caching system like Redis. This allows multiple instances of your application to share the same cache, ensuring consistency and reliability.

Additionally, you should also handle cache invalidation, which is the process of removing outdated content from the cache. This could be done based on certain events, like content updates, or using a more sophisticated caching strategy that combines both time-based expiry and active invalidation.

Conclusion

As you can see, implementing ISR in Astro is not as hard as it might seem. In fact, it's quite simple and straightforward. What I have shown you in this post is the very basic implementation of ISR, but you can, of course, take it much further and build a more robust solution that fits your needs.

I hope this post has helped you understand how to implement ISR in Astro and how you can use it to improve the performance of your site.

Interested in LogSnag?

Get started right now for free!