Reducing Storyblok API usage by 90 percent in your Next.js app
Storyblok is a popular headless CMS that offers a real nice user experience by embedding your frontend into a live editor, so that CMS users see their updates while they change the content. However, when integrating Storyblok into your Next.js app by following the official documentation, the resulting page will kill your Storyblok API usage because caching is not setup correctly. This article explains how we reduced the API usage of one of our customers by 90 percent and how you can do this too.
Before we get into the how, I want to show you prove that our approach works.
)
Storyblok API usage over time
In the chart, we observe that the client operates a highly active website, receiving over 20 million API requests each month. However, there’s a noticeable shift starting Friday, June 27th. On that day, we implemented a subtle but impactful change to the codebase: caching was introduced for Storyblok data. This optimization significantly reduced the volume of requests hitting the Storyblok API. Importantly, analytics confirm that actual user engagement with the website remained consistent, demonstrating that the optimization had no negative impact on user experience.
Estimating a Lower Bound for API Requests
To understand how many API requests are fundamentally necessary to keep content up to date, let’s establish a lower bound.
Assume there are N stories in a Storyblok space and M updates to any story per month. At first glance, it might seem that M requests would suffice (one for each update). However, due to interdependencies (e.g., stories referencing other stories, assets, or data sources), a single update might necessitate refetching multiple stories to maintain consistency.
To be cautious, we can assume that each of the M updates requires us to refetch all N stories. For example, if there are 300 stories and we average 14 updates per week (roughly 2 per day), over a month (approximately 4.3 weeks), the total number of necessary requests is:
300 × 14 × 4.3 = 18,060 requests/month
This conservative estimate still falls well within the limits of Storyblok’s smallest plan, which allows up to 100,000 API requests per month.
The problem: In-memory caching as default
When setting up the Storyblok SDK via the official tutorial, you’re using Storyblok’s built-in in-memory caching by default. If the client has already fetched a story, it won’t request it again, unless it detects a newer cache version from another API request.
The problem? This caching is entirely in-memory. That might work if you're running a single instance of your application (though even then, restarting the app wipes the cache completely). But in modern deployment environments, this scenario is increasingly rare.
For Next.js apps in particular, you're likely deploying in a serverless context, where you have no control over how many instances of your app are running at any given time.
Platforms like Vercel abstract away the infrastructure. Your app can scale automatically: at peak times you might have 100 instances, and at low-traffic times, zero. In this environment, an in-memory cache tied to a single instance is essentially worthless. Each new instance starts with an empty cache, meaning Storyblok gets hit far more often than necessary.
Caching: one of the hardest problems in computer science, except when using Next.js
Okay, that’s a lie. When a single feature needs a 4,000-word guide in the docs, you know caching is still one of the tougher problems in our field.
But, when you configure caching correctly in Next.js, the benefits are crystal clear: fewer hits to your origin APIs, faster responses for your users, and less load on your servers (which might even lower your cloud bills).
For now, let’s focus on just one goal: reducing requests to Storyblok.
To achieve that, we’ll need to work with the Next.js Data Cache. This allows you to persist fetched data across instances—and even across deployments.
The simplest way to tap into this is by using the built-in fetch
function. Next.js adds some powerful enhancements to it (even if monkey-patching built-ins is a questionable move).
Take a look at these two examples:
const url = "...";
const result1 = fetch(url, {});
const result2 = fetch(url, { next: { revalidate: false } })
You can see, that we passed the `next` config option to the fetch call. This tells Next.js that this fetch call can be cached! You can of course configure stuff like revalidation times in this config object as described in the documentation. By using these config options, you can make sure to never refetch content, as long as you know that it hasn't changed.
Improving Storyblok's caching when using Next.js
So now, we need to change how Storyblok fetches data from its API. Luckily, we can simply provide a custom `fetch`-function to the SDK, where we change parameters to enable caching. Let's keep it easy for now and NEVER fetch content from Storyblok again, if we fetched it sometime in the past:
const initResult = storyblokInit({
accessToken: environment.storyblok.accessToken,
use: [apiPlugin],
apiOptions: {
fetch: async (urlOrRequest) => {
const url = new URL(
typeof urlOrRequest === "string" || urlOrRequest instanceof URL
? urlOrRequest
: urlOrRequest.url,
);
// Make sure that the URL is stable and never changes
url.searchParams.delete("cv");
return fetch(url.toString(), {
next: { revalidate: false },
});
},
// If we don't set this, the storyblok api client will internally maintain a rate limit
// we don't want this, since we are using the next.js cache.
rateLimit: 100000,
cache: { type: "none" },
},
});
3 important things to notice:
- We make sure to remove the `cv`-Parameter from the URL so that it never changes, allowing Next.js to cache it forever, because the full URL is part of the cache key for fetch calls.
- We set revalidate to false, telling Next.js to never fetch from the source again if the data is already in the cache.
- We deactivate Storyblok's builtin caching, as having two separate caching layers just makes things more complicated.
One more problem to solve: Of course, CMS content changes over time, so we do need to refetch stories when changes happen. For this we can use Storyblok's Cache Version feature. So let's change the above code so that it periodically checks if a new cache version is available at storyblok:
// Retrieve the current cache version
async function getCv() {
return await storyblokClient
.get("cdn/spaces/me", { version: "published" })
.then((x) => x.data.space.version as number)
}
const storyblokSpaceCacheTag = "storyblok-space";
const initResult = storyblokInit({
accessToken: environment.storyblok.accessToken,
use: [apiPlugin],
apiOptions: {
fetch: async (urlOrRequest) => {
const url = new URL(
typeof urlOrRequest === "string" || urlOrRequest instanceof URL
? urlOrRequest
: urlOrRequest.url,
);
if (url.pathname === "/v2/cdn/spaces/me") {
/**
* Never put the cache verison in the request url for the space,
* as we use this request to get the up to date cache version.
*/
url.searchParams.delete("cv");
return fetch(url.toString(), {
next: {
tags: [storyblokSpaceCacheTag],
revalidate: 60,
},
});
}
url.searchParams.set("cv", (await getCv()).toString());
return fetch(url.toString(), {
next: { revalidate: false },
});
},
// If we don't set this, the storyblok api client will internally maintain a rate limit
// we don't want this, since we are using the next.js cache.
rateLimit: 100000,
cache: { type: "none" },
},
});
So now, for each request, we first fetch the latest cache version from Storyblok. This results in one additional request, which is itself cached thanks to the revalidate: 60
option in the fetch
call for the Storyblok space.
The updated cache version is then included in the URL used to fetch the actual story content. As long as the cache version doesn’t change, the URL stays the same, and therefore the cache key remains unchanged. This allows Next.js to consistently serve the cached version of the story. In the example configuration, the cache version is refreshed once every 60 seconds. Alternatively, you can set revalidate
to false
and use revalidateTag
to manually invalidate the storyblokSpaceCacheTag
via a webhook triggered by Storyblok. With this setup, your CMS content is only re-fetched when something actually changes in your Storyblok space.
This makes the number of API requests entirely dependent on how often you update content, rather than how often users access your site — a much more efficient model.
)
)
)
)
)
)
)
)
)
)
)
)