# Coffee with Claude: Our Strapi cache invalidation journey

> We moved past stale content by creating an open source package for any SSR.

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](https://www.datum.net/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](https://www.datum.net/authors/ronggur-habibun) would commit a fix directly to the cache files. [We're not making this up.](https://github.com/datum-cloud/datum.net/commits/main/) 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 page


As 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](https://github.com/datum-cloud/strapi-revalidate) and [npm](https://www.npmjs.com/package/@datum-cloud/strapi-revalidate) (MIT licensed) and the blog you're reading right now is running on it.

The package does three things:

1. **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.

2. **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.

3. **Invalidates by tag** — when Strapi publishes `api::article.article`, not all caches are cleared, just the `articles` one. The `authors` cache, the `team` cache 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](https://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!

