DEV Community

Peter Jacxsens
Peter Jacxsens

Posted on

On demand revalidation in Nextjs

Revalidation is about updating stale cache. Three functions can trigger revalidation on demand:

  1. revalidatePath
  2. revalidateTag
  3. updateTag

These only work server-side:

  • In a route handler (route.ts): e.g. called by a CMS webhook.
  • In a server action: after a user event like a form submit or clicking a refresh button.

RevalidatePath

revalidatePath invalidates cached data for a specific path. It targets data cache and full route cache if present.

The path can be

  • a specific url revalidatePath('/blog/a')
  • a route handler (route.ts)
  • a route pattern: revalidatePath('/blog/[postSlug]', 'page').

Note the second parameter type ('layout' | 'page'). When revalidating a route pattern, the second parameter is required.

This type parameter changes what is revalidated. Imagine this route setup:

app
  layout.tsx
  blog
    [postSlug]
      page.tsx
Enter fullscreen mode Exit fullscreen mode

When calling revalidatePath('/blog/a'), the root layout.tsx will not be revalidated. This is the same as calling revalidatePath('/blog/a', 'page'):

revalidatePath('/blog/a');
// same as
revalidatePath('/blog/a', 'page');
Enter fullscreen mode Exit fullscreen mode

If content of layout.tsx also needs to be revalidated, use the type parameter 'layout':

revalidatePath('/blog/a', 'layout');
Enter fullscreen mode Exit fullscreen mode

But beware, this will revalidate all paths that use layout.tsx, nested paths as well, f.e. /blog/author/[slug]/page.tsx.

Cache invalidation mechanisms

We covered the stale while revalidate invalidation mechanism earlier in the time based revalidation section. All time-based revalidation uses stale while revalidate. On demand revalidation like revalidatePath is different.

When called from within a route handler, revalidatePath revalidates using the stale while revalidate mechanism. However, when called from inside a server action, revalidatePath will revalidate using the read-your-own-writes mechanism.

read-your-own-writes

read-your-own-writes purges the cache and immediately updates it. This is best explained with an example. Imagine your app has an edit account page, where a user can update their username. This would look like this:

  • User edits their name and clicks the save button.
  • This calls a server action that:
    • updates the DB
    • triggers on demand revalidation

What does the user expect to see after a successful update? Obviously their new username, not the old one (stale data). And that is where read-your-own-writes comes into play. Here is how it works:

  1. The server action calls on demand revalidation.
  2. Affected data is purged from the data cache and the full route cache if needed.
  3. Fresh data is fetched and stored in the data cache. If needed a new full route cache is rendered.
  4. The server action responds (e.g. status) and the fresh data and rendered route are sent along.

So, read-your-own-writes will immediately update the cache, whereas stale while revalidate does not. Server actions are called by user events (form submit, button click). Following this event, the user expects a change in the UI, this is critical.

There is a downside to read-your-own-writes. Updating the DB + revalidating the cache takes some time: the user has to wait. How do you handle this delay? Either a loading state or the useOptimistic hook. stale while revalidate does not suffer from this delay. It immediately returns data, stale data, but still, no wait.

Ok, now that we know about read-your-own-writes, let's return to revalidatePath. When it's called from:

  • Route handler -> stale while revalidate
  • Server action -> read-your-own-writes

stale while revalidate

Why is it fine to have stale while revalidate with route handlers? Let's explain by example:

  • You make a typo in a blog post.
  • You fix it in your headless CMS and save.
  • This triggers a webhook that calls an api endpoint in your app.
  • In this route handler you call revalidatePath for the relevant blog post path (e.g. /blog/post-a).

In this flow, there is no link between the event (happens in the CMS, not the app) and the UI of your app. The first user to visit /blog/post-a will still see the stale data (blogpost with typo). In the background, Next will refetch and rerender and serve the updated content to the next user. This is fine, it's not critical.

Of course, some things are critical - like a typo in a product price. You want that updated ASAP. But, this would require a different caching strategy. For a blog post, it's fine. I hope this makes sense, it all depends on the context.

More limitations

revalidatePath revalidates everything in the path. So, when there are multiple fetches, they will all be revalidated. This might not be desirable. If not, use a different caching strategy.

There is another limitation the docs describe. Let's say we have this tagged fetch (we cover tags below, but you probably know about them already):

fetch(url, { next: { tags: ['product-a'] } });
Enter fullscreen mode Exit fullscreen mode

And we use it in 2 places products/a and products/promos. These 2 routes are statically rendered, that is prerended HTML and rsc. We update products/a with revalidateTag. This will update the data cache (the fetch with tag 'product-a') and the full route cache: .next/server/app/products/a.html and .next/server/app/products/a.rsc.

However, the prerendered products/promos page will not be revalidated. The data cache got updated but the prerendered route will still contain stale data! And that's a problem you can't fix. This is a big limitation when using revalidateTag.

Testing

Note: the examples are available on github.

revalidatePath in route handler (swr)

To run this test, we need a path:

// app/revalidate/path/swr/page.tsx

import Post from '@/components/Post';

export default function Page() {
  return (
    <>
      <Post postId={35} fetchOptions={{}} />
      <Post postId={36} fetchOptions={{ next: { revalidate: 1800 } }} />
      <Post postId={37} fetchOptions={{ cache: 'force-cache' }} />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

We reuse the <Post /> component that we used in earlier chapters. Note that we set cache and time based revalidation. This is to test if all the fetches get revalidated.

We are testing calling revalidatePath from a route handler. This route handler would get called from an external place, e.g. a CMS webhook. This is our route handler, where we call revalidatePath:

// app/revalidate/api/revalidatePath/route.ts
// mostly from example https://nextjs.org/docs/app/api-reference/functions/revalidatePath#route-handler

export async function GET(request: NextRequest) {
  const path = request.nextUrl.searchParams.get('path');

  if (path) {
    revalidatePath(path);
    return Response.json({ revalidated: true });
  }

  return Response.json({
    revalidated: false,
    message: 'Missing path to revalidate',
  });
}
Enter fullscreen mode Exit fullscreen mode

Note that we get the path to revalidate not from a request body but from a searchParam: localApiEndpoint?path=pathToRevalidate. If there's a path we revalidate the path and return a response, else error response.

We will hit this endpoint directly from our browser. So paste url localhost:3000/revalidate/api/revalidatePath?path=/revalidate/path/swr in our browser url bar, hit enter. This will return us some json:

{"revalidated":true}
Enter fullscreen mode Exit fullscreen mode

Note: Initially, I had written a separate route with a button to call this endpoint but that gave serious problems. Our route to revalidate was getting prefetched and saved in the browser cache (later more) and all my tests were failing because of this. To solve this, we call the endpoint directly and I added prefetch false to all the links in our examples overview page (home).

And let's run some tests.

[action]
  next build
  next start
  visit localhost:3000/revalidate/api/revalidatePath?path=/revalidate/path/swr
  get revalidated response
  visit /revalidate/path/swr (path we just revalidated)
  refresh page

[Expect]
  ✅ pass: Fresh content: the timestamp for all posts to have updated
Enter fullscreen mode Exit fullscreen mode

Note: we already did an in depth test for SWR in the previous chapter. From now on, we will only rely on the displayed times of the posts in the front-end; to simplify these tests, e.g.

[TIMESTAMP]   [ID]  [TITLE]
17:24:31      35.   id nihil consequatur molestias animi provident
Enter fullscreen mode Exit fullscreen mode

So, we proved that:

  • Calling revalidatePath from a route handlers revalidates SWR.
  • All the fetches in the route were updated, even though one was cached "force-cache" for infinity and another { revalidate: 1800 }. Calling revalidatePath invalidated them both.

So, everything went as expected, great.

One more test. We have our route /revalidate/path/swr with 3 posts: 35-37. We make a copy of this route with the same 3 posts: /revalidate/path/swr-copy. The goal is to test if this route copy gets revalidated when we revalidate the original route.

We talked about this earlier. revalidatePath will update the data cache for these posts and the full route cache for this path. Our copy should therefore not get revalidated ... because the full route cache for this route copy will not be revalidated. We test it:

// app/revalidate/path/swr-copy/page.tsx

export default function Page() {
  return (
    <>
      <Post postId={35} fetchOptions={{}} />
      <Post postId={36} fetchOptions={{ next: { revalidate: 1800 } }} />
      <Post postId={37} fetchOptions={{ cache: 'force-cache' }} />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode
[action]
  we continue after steps in prev example
  visit /revalidate/path/swr-copy
  refresh page

[Expect]
  ✅ pass: The timestamps did not update
Enter fullscreen mode Exit fullscreen mode

I hope this is clear. Note, a dynamic route copy would update because there would be no full route cache for that route.

revalidatePath in a server action (read your own writes)

We make a new route:

// app/revalidate/path/ryow/page.tsx

export default function Page() {
  return (
    <>
      <div className='mb-8'>
        <Post postId={38} fetchOptions={{}} />
        <Post postId={39} fetchOptions={{ next: { revalidate: 1800 } }} />
        <Post postId={40} fetchOptions={{ cache: 'force-cache' }} />
      </div>
      <RevalidatePathActionButton routeToRevalidate='/revalidate/path/ryow' />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

We're using a server action now. No need to simulate an external webhook call. This is the button:

// components/revalidate/RevalidatePathActionButton.tsx
'use client';

import { revalidatePathAction } from '@/app/revalidate/_actions/revalidatePathAction';
import { useActionState, useTransition } from 'react';

export default function RevalidatePathActionButton({
  routeToRevalidate,
}: {
  routeToRevalidate: string;
}) {
  const [state, dispatch] = useActionState(revalidatePathAction, null);
  const [isPending, startTransition] = useTransition();

  return (
    <div className='my-5 flex gap-4 items-center'>
      <button
        onClick={() => {
          startTransition(() => dispatch(routeToRevalidate));
        }}
        className='px-4 py-2 bg-blue-600 text-white rounded'
        disabled={isPending}
      >
        Revalidat{isPending ? 'ing' : 'e'}
      </button>

      {state && state.revalidated && (
        <p className='text-green-600 text-sm'>Revalidated</p>
      )}

      {state && !state.revalidated && (
        <p className='text-red-600 text-sm'>
          {state?.message || 'An error occurred while revalidating.'}
        </p>
      )}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Note that we use useActionState. We call the dispatch function directly from a button and therefore also need useTransition. The rest of the component is just a button and some status text.

Finally, this is our server action:

// app/revalidate/_actions/revalidatePathAction.ts

'use server';

type RevalidateStateT = {
  revalidated: boolean;
  message?: string;
} | null;

import { revalidatePath } from 'next/cache';

export async function revalidatePathAction(
  _prevState: RevalidateStateT,
  path: string,
) {
  if (path) {
    revalidatePath(path);
    return {
      revalidated: true,
    };
  }
  return {
    revalidated: false,
    message: 'Missing path to revalidate',
  };
}
Enter fullscreen mode Exit fullscreen mode

When there is a path, revalidate it. Note that we need previous state because we call this action inside useActionState.

Great, we build and run it and here's read your own writes in action:

revalidatePath in action

No need to write expects here, we see that it works. The 3 posts update with a small delay. We confirm read your own writes when using revalidatePath from a server action.

Where does the updated data come from? That is just some rsc payload files being sent over.

We also make a route copy without a button, to see what happens.

// app/revalidate/path/ryow-copy/page.tsx

import Post from '@/components/Post';

export default function Page() {
  return (
    <>
      <Post postId={38} fetchOptions={{}} />
      <Post postId={39} fetchOptions={{ next: { revalidate: 1800 } }} />
      <Post postId={40} fetchOptions={{ cache: 'force-cache' }} />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Same as before, the copied route did not update, as expected.

Conclusion

revalidatePath is an easy revalidation solution for pages. There are limited ways to call it:

// specific url
revalidatePath('/blog/a');
// same as
revalidatePath('/blog/a', 'page'); // 'page' optional
// route pattern
revalidatePath('/blog/[postSlug]', 'layout'); // type parameter required: page | layout
// route handler
revalidatePath('/api/revalidate');
Enter fullscreen mode Exit fullscreen mode

revalidatePath behaves differently depending on the context:

  • Route handler -> stale while revalidate
  • Server action -> read-your-own-writes

The upsides to revalidatePath are that it is quite easy to use. The downsides are:

  • It revalidates all fetches which can be undesirable.
  • It will not revalidate other routes that depend on the same revalidated data cache entries.

So, use it with care and probably avoid it in more complex situations.

Let's also take a step back here, to get a good overview. Since we're revalidating, there's a data cache. The page can be dynamic or static. If it's static then there will be a full route cache. Calling revalidatePath will revalidate the data cache and the full route cache (for that path). In a dynamic path, only the data cache will be revalidated.

Note: there is a nuclear option using revalidatePath: revalidatePath('/', 'layout'). This will revalidate everything. If you're using it, you're probably doing something wrong.

If you want to support my writing, you can donate with paypal.

Top comments (0)