DEV Community

Cover image for A guide to Incremental Static Regeneration (ISR) in Next.js using CMS data
JamieBarlow
JamieBarlow

Posted on • Originally published at jamiebarlow-blog.vercel.app

A guide to Incremental Static Regeneration (ISR) in Next.js using CMS data

A practical look at time-based and on-demand ISR with Contentful webhooks


Purpose of this guide

Some background: I have recently been working on a client’s website, and wanted to offer them a clear and user friendly means to independently update content as required. It therefore made sense to use a flexible ‘headless’ CMS setup, with the site built in Next.js, while consuming data from Contentful CMS.

Crucially, I was looking for ways to provide fresh updates to the site as soon as CMS content is published or updated by the client, allowing them to see changes reflected immediately. At the same time, I wanted to ensure that the process of rendering up-to-date content didn’t rely on unnecessary or performance-impacting API calls on every single site visit.

For this purpose, I leveraged Next.js Incremental Static Regeneration (ISR), which is a hybrid rendering strategy that combines elements of static site generation (SSG) with incremental updates for specific pieces of content.

This article starts with a general introduction to ISR, but also covers a deep-dive into different strategies using ISR to serve fresh data in a website or web app - from a basic time interval-based approach to a more involved ‘on-demand’ ISR approach. I explore ways to use CMS features such as Contentful’s ‘Taxonomy’ to support this - which itself promotes good content modelling practice.

I have explored these all within recent work, so will be able to draw on real examples to demonstrate a) how to make this work, and b) where each different approach might be beneficial. I hope that in seeing examples of scenarios in which each approach applies, you should gain a fairly comprehensive mental model of how ISR works in practice.

The how-to guidance focuses on Next.js and Contentful, but similar principles and considerations can apply across other frameworks and CMS options.

Close-up of colorful print samples or swatches, arranged vertically, showing a variety of shades and tones.


What is ISR?

Before Incremental Static Regeneration (ISR) was introduced in Next.js v9.5 in 2020, developers would typically choose between a couple of different standard approaches, each with their own benefits and tradeoffs:

  • Static site generation (SSG) - the site is pre-built, giving fast performance but requiring a full rebuild and deployment every time content is updated. This can be fine if your content doesn’t update frequently;

  • Server-side rendering (SSR) - the site is re-rendered on each user visit, providing up-to-date, dynamic or personalised content, but in doing so requires more frequent data fetching (at request time rather than build time), and can therefore impact performance.

(It’s also worth mentioning Client-Side Rendering (CSR), which is useful for interactivity and use of the browser API after initial render, but it isn’t relevant to server-generated HTML.)

ISR aims to bridge the gap between the two, by allowing static pages to be updated after deployment without rebuilding the entire site. Next.js keeps serving the last generated HTML, then regenerates a fresh version in the background once a specified revalidation period expires or when you trigger a revalidation API call. After regeneration completes, the next visitor receives the updated page.

Because ISR relies on serverless or edge functions to perform the background regeneration, it only works on platforms that support those runtimes (e.g., Vercel, Netlify, Cloudflare). Pure static hosts like GitHub Pages can’t support it.


Basic ISR in Next.js

By default, Next.js pre-renders every page. If a page doesn’t use server-side data-fetching methods (like generateStaticParams in a current App Router setup, or getStaticProps and getStaticPaths in Pages Router), Next.js treats it as static: it will generate HTML at build time.

That means for many pages - especially those that only render static content or rely on build-time data - you get static HTML by default (SSG), not per-request SSR. Next.js achieves this using a built-in Data Cache to allow the result of data fetches to persist across requests and deployments - until we either revalidate that data, or opt-out of caching.


Immediate revalidation

There are a couple of ways to easily force immediate revalidation, which involve opting out of caching.

To opt out of caching any responses using fetch, you can use the { cache: 'no-store' } option:

let data = await fetch('https://api.vercel.app/blog', { cache: 'no-store' })
Enter fullscreen mode Exit fullscreen mode

Alternatively, you can set the caching period with revalidate and set this value to 0:

// Disable caching for preview
export const revalidate = 0;
Enter fullscreen mode Exit fullscreen mode

In either case, this forces the route to fetch fresh data on every request, making it behave like fully dynamic rendering. In practice, this is similar to traditional SSR because the page is re-rendered on each visit instead of being served from a static cache.

I initially took this approach during development, as we wanted to quickly see updates to CMS content in the preview build I had shared with the client.

Immediate revalidation might seem very convenient, but isn't necessarily great for production because:

  • Re-fetching the page on each visit / refresh takes time, as well as more frequent requests to the CMS, which impacts performance;

  • This can impact SEO and potentially even block the site from being indexed by Google’s web crawlers in practice, since they are running multiple requests and re-fetching each time.

So unless you rely on it for constant updates, this approach is generally best avoided. Sometimes it may be a case of limiting immediate revalidation or dynamic rendering to the components that specifically need it, rather than full pages.


Time-based revalidation

A close-up of a flip clock displaying the time 12:20 AM. The clock has a metallic center column with black number panels.

A slightly more effective approach uses time-based revalidation - meaning we can utilise Next.js caching for specified time intervals, and then revalidate only once that interval expires. This can offer relatively fresh content, while reducing unnecessary data fetching and server requests.

In the site’s earliest production build, I initially implemented some very basic ISR by simply adding time-based revalidation to each page:

export const revalidate = 60;
Enter fullscreen mode Exit fullscreen mode

This revalidation interval (60s) offered very quick updates, though Vercel generally recommend setting a high revalidation time, for example 1 hour.

Implementing ISR really can be this simple, especially for a small-scale app or website, but for greater scalability and precision we’ll soon be looking at on-demand revalidation.

Before this I’d next like to cover deploy hooks, since these are sometimes also used as an alternative to time-based revalidation (I’ve seen this frequently in tutorials).


Triggering revalidation with deploy hooks

Close-up of a metallic hook on a wall, with a blurred background. The image is in black and white.

For a given website or app, CMS usage likely goes through different phases: sometimes data is updated frequently, and other times less so. We might therefore consider tying our revalidation not to the same (potentially arbitrary) time intervals, but instead triggering revalidation only when CMS content is actually updated - thereby offering us a little more control.

We can do this with a specific type of webhook, referred to as a deploy hook.

To set this up with Contentful, here’s a brief guide:

  1. In Vercel go to Settings -> Git and scroll to Deploy Hooks. Create a new hook called e.g. 'Contentful Update.' Enter the branch you want to update when content is updated, e.g. 'main.' Click 'Create Hook.'

  2. This will give you an API endpoint URL which you can copy.

  3. In Contentful, go to Settings -> webhooks

  4. Under Templates, you can select one for Vercel. This will prompt you to add the Vercel deploy hook URL (i.e. endpoint) you copied. Click 'create webhook.'

  5. In the next menu you can see all the triggers (i.e. events this webhook should trigger). By default, if you publish or unpublish an Entry or Asset, this will trigger. Fine to leave this as is.

  6. To limit where rebuilds occur, you can also add Filters here for certain Content Type IDs, Entity IDs, Environment IDs, User IDs etc using either specific values or patterns/regexp. This can be useful for e.g. only triggering a rebuild when major layout content (e.g. the navigation or other shared components) or home page content changes - other adjustments might be kept to an ISR approach, so that a minor field change to a single entry doesn't force an entire rebuild.

We should now see that our site delivers fresh content, triggering only when the CMS is actually updated. In theory, this can result in fewer calls for data, especially if updates are infrequent.

However, the drawback of ISR using full deploys is that even small content changes trigger a full rebuild. This approach doesn’t scale well for large sites or frequent updates, and can lead to many unnecessary rebuilds in a short period if multiple small pieces of content are updated individually.

In the next section we'll get to my preferred approach, which makes use of ISR, while avoiding full rebuilds when updating content.


ISR with on-demand revalidation

Sign on a wall with a left-pointing arrow and text that reads

So far we’ve covered time-based revalidation and full builds with deploy hooks. We can see that each have their tradeoffs:

  • Time-based ISR = efficient and scalable but can introduce either content staleness or unnecessary revalidation, depending on the time interval set (it may be hard to define the right interval to achieve an appropriate balance here);

  • Deploy hooks = simple and instantly consistent but potentially expensive and slow;

There is another method which might be considered a sweet spot between the two, offering both flexibility and scalability. On-demand revalidation allows us to quickly revalidate using webhooks that are triggered on each CMS update, but in a more controlled way that targets specific pieces of content rather than the entire site.

This is slightly more involved to setup, but it crucially avoids the main pitfalls of both approaches listed previously - giving you these benefits:

  • Near-instant updates (no time-based revalidation delay);

  • Much lower compute load than full rebuilds (targeted at specific content, and only triggered on content updates).

We’ll look at how to approach this next.


The method

To make this work, we will again employ a CMS webhook, but this time use it to trigger targeted revalidation depending on the specific type of content that is being updated. We do this by creating our own API endpoint, and handling the logic to revalidate data either by specific page or route - or as we will see later, specific ‘tags’ attached to cached content.

This involves a little more consideration about the structure of our CMS content modelling, as we’ll see - but also encourages good practice and maintainability, which is itself beneficial to both smaller and larger scale applications.

  1. Create a secret token known only by your Next.js app. We will be using this later to pass it to our revalidation endpoint.

    To do so with Contentful, follow the approach above (see ‘Triggering revalidation with deploy hooks’) for setting up a webhook. This time, however, we will set up our own endpoint.

    On this: In some guides you'll see instructions to simply pass this directly into the URL using the query parameter https://<your-site.com>/api/revalidate?secret=<token>, and then extract this in your API handler using searchParams.get('secret'). While this may be fine for demo purposes, there is some risk that your webhook exposes this in a real-world application.

    Headers are therefore safer and the approach I use here. Your endpoint can then simply be: https://<your-site.com>/api/revalidate (without a query parameter).

    ❗A point on security: note that secrets in URLs can be easily leaked, either by accident within your app's code, in error logs, or if deliberately exposed. Contentful supports custom secret headers for this purpose. When creating the webhook, it's therefore a good idea to choose 'Add secret header' and enter the secret token key and value here.
  2. Add this secret token to your .env file (for working locally) and also as an environment variable in your Vercel project so that this works in production.

    CMS_SECRET=not-telling-anyone
    

    Create the revalidation API route, ensuring it is at app/api/revalidate/route.ts within your Next.js folder structure (this is assuming App Router*):

    // app/api/revalidate/route.ts
    
    import { revalidatePath } from 'next/cache';
    
    export async function POST(req: Request) {
      const secret = req.headers.get('CMS_SECRET');
      if (secret !== process.env.CMS_SECRET) {
        return new Response('Invalid credentials', {
          status: 401,
        });
      }
    
      revalidatePath('/blog-posts');
    
      return Response.json({
        revalidated: true,
        now: Date.now(),
      });
    }
    

    req.headers.get() allows us to fetch the header (or secret header) passed by the CMS and use this to validate the request.

    revalidatePath() is then used to invalidate the cached version of the page, which triggers the page to regenerate. Note that this triggers regeneration on the next request, rather than immediately.

    💡*Note that this example applies to using Next.js with the App Router (v13+) only - if you are using the older Pages Router, an example is provided in the docs here.
    💡You may have also seen the revalidate() method used in other examples. This is a more 'eager' regeneration method, allowing you to regenerate the cache entry immediately. However, the method is again tied to the Pages Router API - Next.js are working on adding equivalent methods to App Router. It is replaced by res.revalidatePath() for Next.js apps built with app router, as this meets the intended behaviour for the new caching system.

Seeing the results

  1. If you want to see this webhook integration working in practice, in general you will first need to deploy your changes to production.

    As the Next.js docs note, you can technically test this out locally if needed by running next build and then next start to run the production Next.js server. However, unless you have deployed the changes made to your site or app with the new revalidation handler, there will be no actual API endpoint for your webhook to reach! I found it perfectly safe and far more pragmatic therefore to test this in production, especially as the handler should do no destructive work.

    💡(Note this is also workable in a Vercel preview environment, if you temporarily point your webhook to that destination instead - but that goes beyond the scope of this guide).
  2. Try publishing some relevant CMS content, and then refreshing the page with the path specified by revalidatePath() (e.g. blog-posts). You should see the changes reflected immediately, rather than needing to wait for any specified revalidation interval.

    Remember that this is not the same as setting revalidate = 0 which would also result in immediate revalidation, but achieves this by disabling caching. By contrast, here we are exerting more control by triggering revalidation only on CMS updates, rather than with every user refresh.


Revalidating by path

A narrow stone path winds through a lush green meadow, leading towards misty mountains in the background.

For demonstration, the above example simply uses revalidatePath() on a single path. However, you'll very likely want to tie this to any and all paths affected by the update to your CMS.

Instead of listing all available paths on each and every call to the API, you can tailor this to the type of content being updated.


Dynamic routes

The below covers updates to dynamic routes and content e.g. individual blog posts, where a slug is used to create individual pages:

  1. You can use the slug from the webhook payload that Contentful sends:

    revalidatePath('/blog-posts');
    revalidatePath(`/blog-posts/${slug}`);
    revalidatePath('/'); // if needed
    

    The body of Contentful’s webhook request includes the updated entry’s fields, so you can pull the slug from:

    sys.id
    fields.slug
    
  2. To enable this you'll need to ensure your API is setup to take a POST request, so that you can parse the payload from the request body:

    // app/api/revalidate/route.ts
    
    import { revalidatePath } from 'next/cache';
    
    export async function POST(req: Request) {
      // Check secret
      const secret = req.headers.get('CMS_SECRET');
      if (searchParams.get("secret") !== process.env.CMS_SECRET) {
        return new Response("Invalid credentials", { status: 401 });
      }
    
      // Parse webhook payload
      const body = await request.json();
    
      // Contentful delivers fields like: body.fields.slug['en-US']
      const slug = body?.fields?.slug?.['en-US'];
    
      if (!slug) {
        return new Response("No slug found", { status: 400 });
      }
    
      // Revalidate the specific path
      revalidatePath(`/blog/${slug}`);
    
      return Response.json({
        revalidated: true,
        slug,
        now: Date.now(),
      });
    }
    

    Again we’re using revalidatePath() but this time targeting the slug relating to the specific request that is made by our webhook, when that particular content is updated.

    This is much cleaner than a full site rebuild when e.g. updating 1 or 2 blog posts.


Using content types

Alternatively, you might set this up for just types of content according to the CMS modelling structure used by your app or site.

This is a more generalised approach but can work very effectively instead of, or alongside dynamic routes. It will also likely be necessary because much of your content won’t be tied to a specific route, or content types may be shared across multiple pages. I have used this extensively in my most recent client project.

How you go about this will depend greatly on how you have set up your CMS content. To help visualise, you might consider writing out a table like the following:

Contentful Content Type Where it appears on your site
homePage /
aboutPage /about
blogPost /blog list page
contactDetails /, /about
layout Header/Footer → all pages

Once you have mapped these, you only need to revalidate the affected paths for each type of content updated:

To determine which entry has changed, you can again use the webhook's payload from the request body to get the content type, and then choose the relevant path(s) to revalidate:

export async function POST(req: Request) {
  const secret = new URL(req.url).searchParams.get('secret');
  if (secret !== process.env.MY_SECRET_TOKEN) {
    return new Response('Invalid credentials', { status: 401 });
  }
  const body = await req.json();
  const contentType = body?.sys?.contentType?.sys?.id;

  // Now choose paths based on type…
  switch (contentType) {
    case "faq":
      revalidatePath("/faq");
      break;
    case "contactDetails":
      revalidatePath("/contact");
      break;
    case "textBlock":
      revalidatePath("/", "/contact", "/about");
      break;
    default:
      revalidatePath("/", "layout");
  }
}
Enter fullscreen mode Exit fullscreen mode

The switch statement gives you control over which paths to revalidate, which may be a single or multiple paths (depending on the content type).

For our default switch condition, we can apply the revalidatePath() method to the home page (root path “/”), and set the 2nd, optional parameter (type) to 'layout', which invalidates/revalidates all pages below this route.

You may note that content types often don’t map neatly to different routes; especially if your content modelling uses a flexible and composable system, a content type like ‘textBlock’ will likely appear across multiple pages.

Below we'll look at how to exert a little more control over our content system, and thereby allowing our revalidation to be more targeted.


Revalidating paths/routes using taxonomy

Stacks of multicolored paper arranged neatly on shelves, featuring shades ranging from yellow and pink to blue and red.

Mapping using content types alone may not be sufficient to truly profit from ISR. As we saw above, you will likely yourself resorting a little too frequently to revalidating multiple paths - which somewhat undermines the benefits of targeted on-demand revalidation.

We can make use of a feature in Contentful known as ‘Taxonomy.’ You may (as I was) be unfamiliar with this and therefore instinctively prefer Tags, which have been present in Contentful for longer and can be seen as a similar overall concept - but note that Tags are not enforceable and therefore you would need to be reliant on careful management by content editors to keep everything tagged correctly.

Taxonomy, by contrast, enables some benefits:

  • It allows you to enforce validation on content - I prefer to take away the mental overhead here by ensuring that content editors (and ultimately, developers) aren't caught out by failing to enter the right tagging information.

  • It also allows labelling in a consistent and (if needed) hierarchical way - in some ways akin to a traditional CMS - so that items are grouped and organised correctly, which will benefit us later.

(Note: Tags are still worthwhile in other contexts - we will see them in use further below).

Here’s the method for applying Taxonomy:

  1. Open the Taxonomy Manager in Contentful by clicking on your project name (top bar) to open the sidebar.

  2. Add a Concept Scheme. This is your overall grouping of concepts, which you can call 'Belongs to page'. Add a name and description, and auto-populates an ID:

    Concept Scheme

  3. Within this concept scheme, you can add each page as an individual top-level concept, using a very similar method - for example, a home page:

    Concept Scheme example

    The ID (e.g. homePage) will be crucial going forward, as this is sent within the webhook payload to indicate which page's content has been updated. Make a note of each ID as you create each page concept.

    💡An alternative approach you might have noted would be to create 'belongs to page' as a top concept and then individual pages as subconcepts. This can be useful for other, hierarchical content structures, but in our case we are using a flat classification of pages.
  4. For each content type that may be updated by the user, enter the Content Model menu, click 'Taxonomy validations' (sidebar) and add a validation. This will allow you to add the concept scheme we created ('Belongs to page').

    Note that you won't need to do this for the Page content type itself, as each page will carry its own identifier which will allow us to revalidate as appropriate - more on this later.

    You can also ignore any layout content types which are used across multiple pages, such as navigation and footers, as again we will take a separate approach.

  5. When editing or creating a piece of content, you will now see that content changes cannot be published without assigning a Taxonomy concept to the item:

    Taxonomy validation

  6. Within the Taxonomy tab, clicking 'Assign your first concept' (or 'Assign') will open a menu displaying your available pages - here you can assign e.g. Home Page to the item:

    Assigning a taxonomy concept

    Remember to click 'Publish changes' after this.

    For existing content, you'll want to update each item's taxonomy so that our revalidation will work as expected.

  7. Tip: After assigning these, you can create a view in the Content menu which groups items by their taxonomical concept, allowing you to easily see all items associated with a particular page:

    Creating a taxonomy view in Contentful

  8. Now we can look at how to handle the webhook, extending our original approach - which relied solely on content types - by using taxonomy to target specific page updates.

    In Contentful’s data structure, we can map through the webhook payload’s body.metadata.concepts to get each concept ID, and then revalidatePath() against the relevant page:

    interface Concept {
      sys: {
        type: string;
        linkType: string;
        id: string;
      };
    }
    
    // Handling revalidation of page(s) using taxonomy
      const concepts: Concept[] = body?.metadata?.concepts ?? [];
      const pageIds = concepts.map((c) => c.sys.id);
      for (const id of pageIds) {
        switch (id) {
          case "homePage":
            revalidatePath("/");
            break;
          case "about-me":
            revalidatePath("/about-me");
            break;
          case "contact":
            revalidatePath("/contact");
            break;
          case "helpful-links":
            revalidatePath("/helpful-links");
            break;
          case "privacy-notice":
            revalidatePath("/privacy-notice");
            break;
        }
      }
    

    The upshot of this is that a content type such as “textBlock” - which is likely used across multiple pages - can still be tied to a specific page per each instance (assuming you don’t want to reuse that specific text block across pages).

  9. Then as a fallback, if no concept is applied to an item, we can still use the content type, as before.

    This is useful if you have a content type which might be shared across multiple pages, or you otherwise don’t want to tie it to some taxonomy validation for a specific page:

     // Handling revalidation of pages using content type
      const contentType = body?.sys.contentType.sys.id;
      if (concepts.length === 0) {
        switch (contentType) {
          case "faq":
            revalidatePath("/");
            break;
          case "contactDetails":
            revalidatePath("/contact", "/about-me");
            break;
          default:
            revalidatePath("/", "layout");
        }
      }
    

Finally, although the majority of updates will likely be to content nested within a page, we want to be able handle updates to the Page content type itself (such as updates to titles, or re-ordering content).

Remember that adding a 'belongs to page' taxonomy to the Page content type itself is usually redundant/overkill for the content editor. Instead, it makes sense to handle this within your code, using the page id to revalidate these paths too:

switch (contentType) {
      case "page":
        {
          const pageId = body?.sys.id;
          switch (pageId) {
            case "1U10T6wYzOqKLXlgZ3AP06":
              revalidatePath("/");
              break;
            case "1Rxtz0OsdLte04vd9WdoHw":
              revalidatePath("/about-me");
              break;
          }
        }
        break;
    // etc.
Enter fullscreen mode Exit fullscreen mode

Revalidating common layout elements using revalidateTag()

Three hanging tags against a white background: one white, one black, and one brown in color.

For many cases, revalidating an entire path is the easiest and cleanest approach, so all combined approaches so far have used the revalidatePath() method.

However, missing from our handler so far is management of updates to our common layout elements, such as navbar links or footer elements, which aren't tied to specific routes.

Instead of using a fallback to revalidate all routes in these cases, we can exert more control here using the Contentful CMS Tag system and the Next.js method revalidateTag(), along with selective caching. Together, these allow us to target updates to the piece of content itself (treating it as a component), rather than updating an entire page.


Setup

To enable caching, you will need to update your Next.js config file with the cacheComponents option:

//next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  cacheComponents: true,
}

export default nextConfig
Enter fullscreen mode Exit fullscreen mode

Note that opting into this means you can no longer use our earlier time-based revalidation approach, such as:

export const revalidate = 600;   // revalidate after 600s / 10mins
Enter fullscreen mode Exit fullscreen mode

If in some cases you still want to use time-based revalidation alongside an on-demand revalidation approach, you can use cacheLife() to set the cache duration. See guidance in the Next.js docs here.


The method

  1. First we must explicitly cache the data that we want to tag, and there are a couple of ways to do this.

    One is if we use the native fetch API to get our CMS data - here we use the next.tags option:

    fetch(url, { next: { tags: ['navLinks'] } })
    

    In my case, however, I was using the Contentful SDK helpers to replace native fetch calls. Instead I would need to use the “use cache" directive alongside cacheTag():

    export async function fetchNavLinks(include = 10) {
      "use cache";
      cacheTag("navLinks");
      const linksData = await client.getEntries({
        content_type: "navigation",
        include,
      });
      return linksData.items[0].fields;
    }
    

    The data returned from the function will now have this tag applied, which we can call later.

  2. You can then call this function within a specific component, for example:

    interface NavLinksProps {
      className?: string;
    }
    
    const NavLinks = async ({ className }: NavLinksProps) => {
      const navLinks = await fetchNavLinks();
      return (
        <div
          id="navLinks"
          className={`flex gap-10 navLink items-center text-base-100 ${className}`}
        >
          {navLinks.links.map((link, index) => (
            <NavLink href={link.fields.url} key={index}>
              {link.fields.name}
            </NavLink>
          ))}
        </div>
      );
    };
    
    export default NavLinks;
    

    Because data fetching and caching are isolated to the components that consume the data, revalidation only impacts those components, leaving unrelated data on the same page untouched.

  3. Now we can use revalidateTag() to revalidate this cached data on-demand. In my case, there is a 'navigation' contentType in my CMS setup, which means that I can do the following within my revalidation handler:

    const contentType = body?.sys.contentType.sys.id;
        switch (contentType) {
        case "navigation":
          revalidateTag("navigation", "max");
          break;
        // etc.
    }
    

    Note that the 2nd argument, profile="max" is recommended in order to mark tagged data as stale until the page is next visited.


Verifying your revalidation

To check this is working as expected, you can take a couple of measures. One is to test locally by running next build and then next start to run the production Next.js server.

You can also add the following environment variable to your .env file, which allows for some further logging and debugging:

NEXT_PRIVATE_DEBUG_CACHE=1
Enter fullscreen mode Exit fullscreen mode

Yet another way is to add explicit logging (as in the above examples) to your Response object, which I found very useful during testing. For example:

const pathsRevalidated = [];
if (concepts.length === 0) {
    switch (contentType) {
      case "faq":
        revalidatePath("/");
        pathsRevalidated.push("/");
        break;
      case "contactDetails":
        revalidatePath("/contact");
        pathsRevalidated.push("/contact");
        break;
      default:
        revalidatePath("/", "layout");
        pathsRevalidated.push("all paths");
    }
  }

return Response.json({
    revalidated: true,
    now: Date.now(),
    typeUpdated: contentType || null,
    pagesUpdated: pageIds || null,
    pathsRevalidated,
  });
Enter fullscreen mode Exit fullscreen mode

This will be viewable within the Contentful interface - check the activity log for your webhook for the response data, which will tell you the page that has been revalidated.


Uncached data errors

Note that in enabling cacheComponents, you will now need to ensure you handle any async component that reads uncached data correctly. This means that you will need to employ caching, or otherwise provide a fallback.

Otherwise you will likely see the error:

Uncached data accessed outside of <Suspense>. This delays the entire page from rendering, resulting in a slow user experience.
Enter fullscreen mode Exit fullscreen mode

As noted in the Next.js docs here, you can therefore take one of two approaches for any component that awaits data:

  • Wrap the component in a <Suspense> boundary, with a fallback - this is only needed if you want to access fresh data on every user request, which is not our use-case;

  • Use the "use cache" directive on any data fetching actions (as above), to convert these into cache components. This allows Next.js to statically prerender the component as part of the HTML document, so it's instantly visible to the user. Note that you do not need to use cacheTag() as well if not required.


Taxonomy for assets/images

A hand holds a blank polaroid photo with a blurred room in the background.

So far, we haven’t covered assets/images. These tend to be treated a little differently in CMS systems, and I think highlight a key consideration over how we balance controlled validation against flexibility, and the 'best' approach will vary according to your needs.

You might want to add taxonomy to certain images too, to link these to specific pages/routes, but the tradeoff here is that you add even more editorial overhead, especially if you have a large number of assets and want to use them interchangeably across pages. It's worth noting that this is a general downside of taking the above approach to 'tagging' individual pieces of content - if reusability is your priority, then this won't work so well for you, unless you are happy to e.g. tag multiple pages against a single reused item, which might become confusing.

Because image updates are arguably updated less frequently than text copy, there is a case here to justify revalidating all relevant routes, which is the approach I have taken.

Note that while images also have a contentType (e.g. image/jpeg), their data structure in Contentful is different from other content model types, which have an overall type of "Entry". You therefore need to handle these separately, filtering for a Type of "Asset":

// Handling assets e.g. images
  const sysType = body?.sys?.type; // Entry or Asset
  if (sysType === "Asset") {
    revalidatePath("/", "layout");
    pathsRevalidated.push("all paths");
    return Response.json({
      revalidated: true,
      now: Date.now(),
      pathsRevalidated,
    });
  }

// Handling entries...
Enter fullscreen mode Exit fullscreen mode

It’s a good idea to do this early in your route handler, so that assets can be processed or excluded up front, avoiding unnecessary branching later and ensuring the remaining logic only operates on entry-based content.


Conclusion

As we have seen, Incremental Static Regeneration is most powerful when treated not as a single feature, but as a spectrum of strategies, each applicable in different cases. In my client projects, all are used to cover different types of content - depending on whether they are ‘higher level’ elements like pages, text or copy linked to specific pages/routes, reusable or common layout elements, or media assets.

By combining approaches such as time-based caching, on-demand revalidation, and fine-grained control via paths, taxonomy and tags, you can deliver content that feels instant to users while remaining efficient and scalable behind the scenes.

When paired with thoughtful CMS modelling, ISR enables a workflow where editors see changes reflected immediately, without forcing unnecessary rebuilds or sacrificing performance.

Top comments (0)