Caching in Next.js with unstable_cache

leemeganj

Megan Lee

Posted on May 22, 2024

Caching in Next.js with unstable_cache

Written by Kapeel Kokane✏️

In programming, caching is when you store the results of an expensive operation so that you can reuse them later. This can be useful when a function takes a long time to run or when a function is called multiple times with the same input.

The same concept can be extended to web development. Caching can be applied to the response of an API call or even a whole page being returned from the server. In this article, we will learn how to cache data in Next.js using the unstable_cache method introduced in Next.js v14. We will also compare the Next.js cache method with the React cache method to understand the use cases in which you would use one over the other.

Introduction to unstable_cache

The unstable_cache function is a new addition to the Next.js cache API. Next.js already had several high-level APIs that we will discuss later in this article. But unstable_cache is a low-level cache API that allows you more granular control over the caching.

It is important to note that this API is still unstable and its contract might change in the future. Nonetheless, it is a powerful tool that can be used to cache the results of database queries or API calls.

Here's an example of how the unstable_cache API can be used:

const data = unstable_cache(func, keys, options)()
Enter fullscreen mode Exit fullscreen mode
  • func is the function that we want to cache. It must always return a promise
  • keys is an array of globally unique values that together identify the key of the data being cached
  • options is an object that lets us control some more granular aspects of the caching, like adding tags that can be used to later invalidate the cache. Or passing a revalidate parameter, which is the number of seconds after which the cache should be revalidated

Notice the () at the end of the function call. This is because unstable_cache returns a function that we need to call to get the cached data.

The need for the unstable_cache API

You might be wondering why we need a low-level cache API when Next.js already provides high-level caching mechanisms mentioned in the documentation. Let us first understand those mechanisms so that we can appreciate what unstable_cache brings to the table.

Request memoization

With request memoization, the fetch API automatically memoizes requests that have the same URL and options during a render cycle of the React component tree.

Let’s say we have a server-rendered React component called Parent that looks something like this:

export default function Parent() {
  return (
    <div>
      <Child/>
      <Child/>
      <Child/>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Say that the child component has some code to make an API call like this:

async function getData() {
  const res = await fetch('https://.../data')
  return res.json()
}
const item = await getData() 
Enter fullscreen mode Exit fullscreen mode

Notice that there are three Child components inside the Parent component. This means that the function getData will be called three times. But, because of request memoization, the fetch API will only make the API call once and return the same result for all the calls without the need for any explicit configuration. That is because the rendering of these three child components is happening in the same render cycle of the React component tree.

Data Cache

Then, there is the Data Cache that persists API results across server requests. Caching is enabled by default, and we can configure it in a more granular manner using the cache option in the fetch request. This is different from request memoization because the Data Cache persists the results across server requests.

Continuing from our previous example, if we refresh the Parent component page after its initial render, the API call won't reach out to the original data source ('https://.../data'). Instead, it returns the cached result from the Data Cache, which was filled during the previous request. To prevent this default behavior and fetch from the original data source, we can pass the option { cache: 'no-store' } to the fetch request.

Full Route Cache

The Full Route Cache is the type of caching wherein Next.js automatically renders and caches routes at build time. To understand this, we need to understand the steps that take place every time a component is rendered in Next.js:

  • React converts Server Components into a special data format called React Server Component Payload
  • Next.js uses the React Server Component Payload to render the resulting HTML on the server
  • Next.js then caches these results, which include both the HTML and the React Server Component Payload for that particular route
  • The HTML is sent to the client where a non-interactive preview of the components is displayed, which is later hydrated to make the application interactive

Thus, a static route gets cached in the Data Cache at build time and a dynamic route gets cached the first time it is accessed.

Router Cache

The Router Cache is an in-memory version of the cache that is stored on the client (browser) as opposed to the Full Route Cache, which gets stored on the server. An interesting thing to note is that the Router Cache is split by route segments. This means that if we have a common layout for pages A and B, then the layout gets cached when either of these two components is rendered. The layout does not need to be rendered the second time because it has already been cached.

unstable_cache API

With all of that understood, we can now learn where the unstable_cache API fits in. In our previous examples, the common underlying assumption was that all the data fetching would happen via the fetch API. As the fetch API is already modified by Next.js to support memoization, we did not have to do much apart from configuring a few options.

But, what about the cases where our external data comes from some DB clients or GraphQL clients? In those cases, we would not be able to use the fetch API and that is when a function like unstable_cache is necessary to wrap the database call function and explicitly memoize it.

Right now, the Next.js official documentation suggests using the React cache function for this purpose because the Next.js cache API is still unstable. In a later section, we will cover the differences between the Next.js cache API and the React cache API.

An example of the unstable_cache API

In this section, we will follow a hands-on example to better understand how the unstable_cache API works. We'll use the Next.js CLI to create a fresh project:

npx create-next-app@latest nextjs-cache-example
Enter fullscreen mode Exit fullscreen mode

Answer the prompts and then navigate to the project directory to start the dev server:

npm run dev
Enter fullscreen mode Exit fullscreen mode

This brings up the Next.js dev server on [http://localhost:3000](http://localhost:3000), which looks like this:

Next.js Homepage

Create a new folder inside the app directory called tasks. This folder will hold the page.js that links to the /user route. This route will show information related to a user including their ID, name, and email.

Creating a KV store

To test the capabilities beyond the fetch API, we will be creating a key-value store using Vercel storage. You can follow this quickstart guide on the official Vercel documentation to create a KV store connected to your project.

After creating the store and connecting it with the project, we will use the CLI from the Storage menu of the project. We will run this command to create a hash key-value pair:

hset user:123 email email@me.com id 123 name "Test User"
Enter fullscreen mode Exit fullscreen mode

Setting up the page

We will now set up the /user route to fetch the data from the KV store. Once we have that JSON, we will map over the keys to show the key-value pairs on the page. This is what the component code inside the page.js file looks like:

import styles from "../page.module.css";
import { kv } from '@vercel/kv';

export function getUser() {
  return kv.hgetall('user:123');
}

export default async function User() {
  const user = await getUser();

  return (
    <main className={styles.main} >
      <div className={styles.description}>
        {Object.keys(user).map((key) => (
          <div key={key}>
            <code className={styles.code}>{key}:</code> 
            {user[key]}
          </div>
        ))}
      </div>
    </main>
  )
}
Enter fullscreen mode Exit fullscreen mode

This is the information it displays based on the data that we added to the KV store:

Key Value Store

Creating an edit page

We will now create an edit page, which will be a client-side route and will have a single button to allow us to edit user information.

First, create a page.js file inside the edit folder inside the app directory. This is what the component code looks like:

'use client'
import styles from "../page.module.css";
import { editUser } from '../actions.js';

export default function Tasks() {
  return (
    <main className={styles.main} >
      <div className={styles.description}>
        <button className={styles.button} onClick={async () => {
          await editUser()
        }}>Edit User</button>
      </div>
    </main>
  )
}
Enter fullscreen mode Exit fullscreen mode

Notice the 'use client' at the top of the component that makes this a client-side route and allows us to configure action handlers like the onClick handler. This is how the page looks, with just a single button:

Edit User Button

We can also see that we are triggering a server action called editUser when the button is clicked. This is what the actions.js file looks like:

'use server'
import { kv } from '@vercel/kv';

export async function editUser() {
  try {
    await kv.hset('user:123', { id: 123, email: 'test@me.com', name: 'Test User' });
  } catch (error) {
    console.log('error while editing user')
  }
}
Enter fullscreen mode Exit fullscreen mode

Notice the 'use server' at the top of the page. Also, notice how we can directly write code that talks to the KV store as we are sure this code will run on the server.

We have configured the code such that when we click the Edit User button, the email of the user will change to test@me.com and we are expecting it to reflect on the /user page.

But when we click the button and visit the /user page, we see that the email has not changed. This is because of the Full Route Cache that we mentioned earlier. Here's a GIF that shows the edit action and the user page:

Editing The User

Fixing the caching issue

Next.js has a utility that allows us to fix this issue. The utility is called revalidatePath and it is exported from the next/cache module. We can use this utility to invalidate the cache of the /user route when the user information is edited. This is what the updated actions.js file looks like this:

try {
  await kv.hset('user:123', { id: 123, email: 'test@me.com', name: 'Test User' });
  revalidatePath('/user')
} catch (error) {
  console.log('error while editing user')
}
Enter fullscreen mode Exit fullscreen mode

By calling the revalidatePath function, we are telling Next.js that modifying the user information in the DB should invalidate the cache of the /user route and that Next.js should re-render the /user route with the updated information.

Now, when we click the Edit User button, the email of the user changes to test@me.com and it also gets updated on the /user page. Here's a GIF that shows the edit action and the user page: Testing User But there's a small caveat with this approach. Because we modified a small detail for a user, we invalidated the cache for the whole /user route. In real-world applications, it might be the case that the route shows us lots of information and the user information might just be a small part of it. In such cases, we would prefer a more granular control over the cache invalidation.

Using unstable_cache for granular control

This is where the unstable_cache API can be useful. We will first cache the result of our call to the DB inside our page.ts in the /user route. This is what the updated code looks like:

import { unstable_cache } from 'next/cache';

export function getUser() {
  return kv.hgetall('user:123');
}

const getCachedUser = unstable_cache(
  async () => getUser(),
  ['my-app-user'],
  { tags: ['user'] }
);

export default async function User() {
  const user = await getCachedUser();
  // rest of the component
}
Enter fullscreen mode Exit fullscreen mode

Notice the unstable_cache function that takes our original getUser function as an argument. We have dual benefits from using this function:

  • Firstly, we have cached the result of the call to the DB. This means that the call to the DB will only happen once and the result will be cached for subsequent calls
  • Secondly, we are also passing the tags option with the value ['user']. This is important because we can use the tags option to have granular control over the cache invalidation

To take advantage of this granular control, we just have to change the revalidatePath(/user) to revalidateTag('user') in the actions.js file. And that's it! To test out the code, let’s change the email to testing@me.com this time:

await kv.hset('user:123', { id: 123, email: 'testing@me.com', name: 'Test User' });
Enter fullscreen mode Exit fullscreen mode

Here's what the updated behavior looks like: Updated User Button Action It looks the same as the previous example but this time we have a more granular control over the cache invalidation. We can now invalidate the cache of only the API and not the entire /user route.

React cache API

At this point, we also need to talk about a React function that is similar to the unstable_cache function. This function is called the cache function and it is exported from the react module. This is what the code looks like:

import {cache} from 'react';
import calculateReport from 'lib/reports';

export const getReport = cache(calculateReport);

function Chart({data}) {
  const report = getReport(data);
  // ...
}
Enter fullscreen mode Exit fullscreen mode

As we can see, this takes just one argument, which is the function we want to cache. When we want to cache an API call, that function will be an async function that wraps that API call. While calling the function, we need to pass the arguments that we would normally pass to the uncached function. If the cached function is called with the same argument again, the cached result will be returned.

While this can be useful, we would prefer to use the Next.js cache API because of how seamlessly it integrates with the invalidation strategies that Next.js provides.

For example, unlike before, there is no way to link the cached function with tags and then use it for invalidation.

The React cache function is best utilized for caching expensive calculations inside client components where invalidation is not a concern. Another good use case for the React cache function is when you want to "prefetch" some data.

Let’s say that we have a parent component similar to the Chart component that calls the cached getReport function but does not use its result. In that case, it might look like this:

function Chart({id}) {
  getReport(id);
  return (
   <>
     <MiniChart id={id} />
   </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Then, the MiniChart function is the one that actually uses the data returned by the getReport function:

import getReport from './Chart;

function MiniChart({id}) {
  const report = getReport(id);
  return (
   <>
     // use the report here
   </>
  );
}
Enter fullscreen mode Exit fullscreen mode

In this way, the parent pre-fetches the data even though it does not use any of it. However, the data is more likely to be available by the time the child component gets to use it.

Conclusion

In this article, we learned about the different caching strategies in Next.js. We explored the unstable_cache function and learned how it can be used to cache the result of DB queries that do not involve the fetch API. The use of tags also offered granular control over cache invalidation, enhancing efficiency.

It is great to see that Next.js finally has a low-level cache API that can be used to cache the results of expensive operations. Developers now have a choice between this and higher-level caching methods, or integrating the React cache function, based on their specific needs.


LogRocket: Full visibility into production Next.js apps

Debugging Next applications can be difficult, especially when users experience issues that are difficult to reproduce. If you’re interested in monitoring and tracking state, automatically surfacing JavaScript errors, and tracking slow network requests and component load time, try LogRocket.

LogRocket Signup

LogRocket is like a DVR for web and mobile apps, recording literally everything that happens on your Next.js app. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app's performance, reporting with metrics like client CPU load, client memory usage, and more.

The LogRocket Redux middleware package adds an extra layer of visibility into your user sessions. LogRocket logs all actions and state from your Redux stores.

Modernize how you debug your Next.js apps — start monitoring for free.

💖 💪 🙅 🚩
leemeganj
Megan Lee

Posted on May 22, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related