There's a particular flavor of frustration that comes from publishing a hard-won blog post, making a few small edits that you noticed right after pushing to production, and then endlessly refreshing to try in vain to see the latest version. For somewhere between a minute to forever, you don’t get a 404 or an error, just...the old thing. Still there, happily and confidently incorrect.
This was a recurring experience for us. We run our blog and team pages on Astro SSR with Strapi Cloud as the CMS. Publishing content with Strapi is great, the GraphQL API is fast (more on that in a minute), and folks from across our team can use it without opening a ticket.
In our case, the sticky content problem wasn't Strapi itself. Instead, once we fetched content from Strapi and cached it, nothing told our running server when things had changed. This meant posts would sit there, invisible, and team bios would show outdated job titles.
The go-to fix was embarrassingly manual: open the post in Strapi, add a period to the excerpt, and save. Watch the cache refresh. Delete the period. Save again. If that didn't work (and sometimes it didn't!) my colleague Ronggur would commit a fix directly to the cache files. We're not making this up. It's in the commit history.
Here's what happened when we finally got fed up enough with the problem to architect a fix.
The original workaround
Here is what we were working around:
Our codebase had a Cache class that wrote JSON files to .cache/ with a .expires companion file. TTL of 30 days. On a cache miss, fetch from Strapi and on a hit, serve the file. In the case of a Strapi outage, it would fall back to a persistent copy that was set to never expire (heads up: this bit was actually smart and we kept it).
We also had npm run build:cache which is a warmup script that pre-populates the cache before a build. It’s useful but requires a deploy cycle, which means there is no great answer to publishing a post at 11pm and expecting it to be live soon after.
The root issue was actually pretty simple: nothing was telling the server that Strapi had changed something. And while Strapi has had webhook support forever, we hadn't wired it up.
Going past the obvious fix
When we went looking to see if this already existed, we found dozens of repos called strapi-cache or similar. Every single one is a Strapi plugin meant to live in config/plugins.js, run inside the Strapi server process, and cache Strapi's own API responses before they leave the server.
Those solve for Strapi's API performance, but they have zero awareness of your frontend. They can't clear your server's cache when a publish happens, because they're on the other side of the network call.
Enter our webhook handler. Here’s how it works: Strapi fires POST /api/revalidate on publish, the server deletes the relevant cache files, and the next request fetches fresh data.
sequenceDiagram participant Editor as Content Editor participant Strapi as Strapi Cloud participant Hook as Webhook Handler participant Cache as Cache Manager participant SSR as SSR Server participant Visitor as Site Visitor
Editor->>Strapi: Publish article Strapi->>Hook: POST /api/revalidate Hook->>Cache: Invalidate tag (strapi-articles) Cache-->>Hook: Done
Visitor->>SSR: GET /blog/my-post SSR->>Cache: Cache miss — fetch fresh Cache->>Strapi: GraphQL query Strapi-->>Cache: Response Cache-->>SSR: Cached + tagged SSR-->>Visitor: Fresh pageAs expected, this fix did the job just fine. But while we were in the code, Ronggur made another observation: "GraphQL feels randomly faster from Strapi, at least on the free tier." He's right, but it's not random! GraphQL lets you ask for exactly the shape you need in one query, so you're not over-fetching or making multiple round trips to assemble a complete object. On Strapi Cloud's shared infrastructure, a tight 2kb GraphQL response consistently beats an 18kb REST payload with half the fields unused.
That nudged us from "fix the webhook" to "fix this properly." And fixing it properly meant admitting that the real problem wasn't specific to our framework (Astro) or any other. Any SSR server that caches Strapi content has this problem_._ The webhook handler, cache drivers, and fallback layer have nothing to do with Astro, but are useful to everyone.
So we extracted it into an open source package.
@datum-cloud/strapi-revalidate
It's on GitHub and npm (MIT licensed) and the blog you're reading right now is running on it.
The package does three things:
-
Caches Strapi content locally — file-based by default (the same dual-cache pattern from our original code, extracted and cleaned up), in-memory for dev and testing, Redis for multi-instance deploys when you get there.
-
Handles Strapi webhooks —
createWebhookHandler()returns a plain async function you mount however your framework does it. Astro API route, Next.js route handler, or SvelteKit endpoint. It’s one paragraph in the README per framework with no framework-specific package required. -
Invalidates by tag — when Strapi publishes
api::article.article, not all caches are cleared, just thearticlesone. Theauthorscache, theteamcache all stay in place, warm and at the ready.
The fallback layer is still there, mandatory and not configurable because a flaky CMS shouldn’t take down your frontend. When Strapi is unreachable, you serve stale data instead of errors.
And for all of the speed demons out there, don’t worry: strapi-revalidate lives in your frontend SSR process, so it’s fine to use it along side strapi-cache. They won't conflict.strapi-revalidate lives in your frontend SSR process, so it’s fine to use both of these in tandem; the two approaches are complementary — if you're already running strapi-cache on your Strapi instance, keep it. They won't conflict.
What the migration looked like on our end
We introduced a _runtime.ts file that wires the client, cache manager, and config once at module load. Every Strapi fetcher and the webhook route imports from there, so timeouts, retries, cache directories, and tag conventions stay in one place.
The fetch helpers themselves barely changed. fetchStrapiArticles() still reads cache first, fetches from GraphQL on a miss, falls back to the persistent copy on a Strapi error. The difference is it now calls into the package's CacheManager instead of the inline implementation, and cache keys carry tags so the webhook handler knows what to clear.
One thing we had to handle carefully: legacy fallback keys. Our old cache wrote files named articles.json and article-{slug}.json. The new package uses strapi-articles and strapi-article-{slug}. During the cutover, if Strapi happened to be unreachable, we'd have lost access to the existing stale data. So we kept legacy key reads as a backstop for one deploy cycle:
const fallback = (await cache.getFallback<StrapiArticle[]>(ARTICLES_CACHE_KEY)) ?? (await cache.getFallback<StrapiArticle[]>(LEGACY_FALLBACK_LIST_KEY));It’s not an elegant solve, but it meant our migration would be zero-downtime even if everything went wrong. We removed the legacy reads in the next deploy once the new fallback files had been written.
The conversation that shaped the package
The package almost ended up as astro-strapi. Ronggur pushed back: "Let’s not be specific to Astro. PPeople choose Astro because they are interested in SSG which does not need cache."
He's half right. Pure SSG doesn't need runtime cache invalidation because there's no runtime. But naming it astro-strapi would have been wrong regardless. The webhook handler, the cache engine, the GraphQL client aren’t specific to Astro, so naming it that way is like naming a database driver after the first app that used it.
We also checked if strapi-revalidate was taken on npm and it wasn't. The name describes what it does, not which framework you're in.
What's next
Since we’re already in the flow, we’re keeping at it. A Redis driver is stubbed but not yet implemented because if you're on a single instance (most people are), file cache is fine. If you're running behind a load balancer, you'll want a shared cache layer, so that's the v1.x work.
REST transport is also stubbed. GraphQL is the default and we'd recommend it for the performance reasons above, but some Strapi setups have the GraphQL plugin disabled.
Issues and PRs welcome at github.com/datum-cloud/strapi-revalidate. If you've been running a Strapi + SSR stack and your go-to hack involves editing a period in an excerpt, then this is the fix for you!
