@towa-digital/storyblok-nuxt-cv
v0.5.0
Published
Keeps Storyblok published content fresh on long-running Nuxt servers by bridging the live cache version (cv) to the render client — no redeploy after publish.
Readme
@towa-digital/storyblok-nuxt-cv
Keeps published Storyblok content fresh on long-running Nuxt servers — no
redeploy after publish. Packages the aichelin-multisite 1.5.5 storage-bridge
fix as a drop-in Nuxt module.
Maintained by TOWA. Design rationale and the full
bug analysis live in TOWA's internal storyblok-cv-audit playbook repo
(docs/package-design.md, docs/problem.md).
The problem (short version)
storyblok-js-client pins the space cache version (cv) in a process-global
map for the process lifetime, and Storyblok's CDN serves a frozen snapshot
for every URL warmed under that cv (7-day s-maxage). On client v6 nothing
ever heals the pin — published content stays stale until redeploy; on v5
and v7 the pin self-heals on the first cache-missing response, which presents
as unpredictable per-worker flapping after every publish. In production
builds the render client is a separate bundled copy of the client that
Nitro code can't reach, so a plain server-side flushCache() webhook does not
fix the render path.
What this module does
- Nitro middleware attaches the stored live
cvto every page render'sevent.context; an app plugin (compiled into the same bundle as the render client) pins it viauseStoryblokApi().setCacheVersion(cv)before content is fetched, and forwards it to the browser through the payload. - The stored
cvis kept fresh by three composable strategies:- startup init (on by default) — populates the store at boot,
- publish webhook (built-in route, or one line in your existing handler),
- TTL stale-while-revalidate — for serverless/edge and multi-instance.
- Works with
storyblok-js-clientv5 → v7 and@storyblok/nuxt5 → 9: the server side never imports the client (rawcdn/spaces/mefetch), the app side only usesuseStoryblokApi/setCacheVersion, which are stable across all those majors. Keeps Storyblok'scv-keyed CDN caching intact (unlikecache: { cv: 'manual' }).
Install
Requires @storyblok/nuxt (any major from 5 to 9) in the host app.
yarn add @towa-digital/storyblok-nuxt-cv// nuxt.config.ts
export default defineNuxtConfig({
modules: [
['@storyblok/nuxt', { accessToken: process.env.STORYBLOK_TOKEN }],
'@towa-digital/storyblok-nuxt-cv',
],
})That's it for a single-process Node server (DigitalOcean preset etc.):
token is auto-detected from the @storyblok/nuxt config, startup init and the
built-in webhook route are on by default.
Then create a story publish webhook in Storyblok
(Settings → Webhooks, story events) pointing at
https://<site>/api/storyblok-cv-webhook, set a secret, and provide it as
NUXT_STORYBLOK_CV_WEBHOOK_SECRET.
Configuration
export default defineNuxtConfig({
storyblokCv: {
token: undefined, // default: auto-detected from @storyblok/nuxt config; env NUXT_STORYBLOK_CV_TOKEN
region: 'eu', // eu | us | ap | ca | cn
endpoint: undefined, // full API endpoint incl. /v2 (like @storyblok/nuxt); overrides region — for proxied spaces
storage: 'storyblok-cv', // nitro storage mount name
fsStore: false, // true → module mounts an fs store at <cwd>/.data/<storage> at RUNTIME (node/PM2; owner-correct, per-deploy). string = custom base.
statusRoute: '/api/storyblok-cv', // GET current cv; false to disable
webhook: true, // built-in route /api/storyblok-cv-webhook; string = custom route; false = use refreshStoryblokCv() yourself
ttl: false, // seconds, e.g. 60 — background refresh when the stored cv is older (serverless/multi-instance)
onStartup: true, // populate the store once at boot
},
})Deployment-target matrix (pick your refresh strategy)
| Target | Storage | Strategy |
|---|---|---|
| Single Node process (DO app platform, plain node-server) | default (memory) | webhook (default setup) |
| PM2 cluster on one host (instances: 'max') | fsStore: true (or a manual fs mount) | webhook |
| Multi-node / containers | mount Redis | webhook |
| Cloudflare / Vercel Edge / Netlify (isolates) | default | ttl (seconds, e.g. 60) — a webhook can't reach every isolate |
On a PM2 cluster memory is per-worker, so a webhook only updates one worker.
The simplest correct fix is fsStore — the module mounts an fs store at a
path resolved at runtime, inside the app's own directory (owned by the
runtime user, unique per deployment):
storyblokCv: { fsStore: true } // → <cwd>/.data/storyblok-cv (or fsStore: '/abs/path')Prefer this over hand-configuring nitro.storage — it's owner-correct and
per-deploy, so it sidesteps the gotchas below. Node/PM2 only — serverless/edge
have no writable fs, use ttl there.
nitro: { storage: { 'storyblok-cv': { driver: 'fs', base: '/absolute/writable/path' } } }Use an absolute path writable by the runtime user. A relative base is
resolved at build time (it bakes the build/CI cwd, not the server's) — which
is exactly what fsStore sidesteps by resolving at runtime. Persist failures are
logged as [storyblok-cv] … FAILED to persist.
If unsure, setting ttl in addition to the webhook is a safe belt-and-braces
default: staleness is then bounded by the TTL even if webhook delivery breaks.
Integrating with an existing webhook
Most TOWA projects already have a publish webhook (update-redirects.post.ts
and friends). Set webhook: false and add one line — refreshStoryblokCv is
auto-imported in server code:
// server/api/update-redirects.post.ts (existing handler, after signature check)
await refreshStoryblokCv()Multi-tenant spaces: refresh on every publish (do not tenant-filter)
Refreshing on every content publish in the space is the right behavior even
for multi-tenant shared spaces where only one tenant's content changed — so the
default needs no configuration. Filtering out sibling tenants' publishes (as
aichelin 1.5.5 originally did) looks like it protects your CDN cache, but it
only protects URLs that were already warm: cold fetches get 301-corrected to
the latest cv and fill the cache under the new generation anyway, while your
pinned client keeps paying that miss+301 hop on every fetch of them until your
own next publish. It also serves stale resolved content if tenants share any
references or datasources. Adopting the sibling's cv instead costs one origin
fetch per URL identity, once.
For genuinely special cases (e.g. origin rate-limit pressure from extremely
frequent sibling publishes), the storyblok-cv:webhook nitro hook can veto a
refresh: handlers receive { payload, refresh } and may set refresh = false.
Prefer throttling over hard filtering if you reach for this.
What this module does NOT solve
- ISR / page-cache staleness: a fresh
cvdoesn't invalidate HTML already cached byrouteRules: { isr: true }or a CDN page cache in front of the app. - URL-shape hygiene: inconsistent trailing slashes in
cdn/stories/...paths create a second cache identity per story (flaky staleness). Normalize in the app; see the audit playbook'sdocs/remediation.md. - On client ≥ 7.4.0 (check the lockfile — the option doesn't exist before
7.4.0) you can alternatively set
cache: { cv: 'manual' }and skip this module — at the cost of losing Storyblok'scv-keyed CDN caching (every SSR fetch hits origin). This module keeps CDN caching, and covers v5/v6/early-v7 where no such option exists.
Verifying an installation
Don't trust a single "looks fresh" check. Use the lab method from the audit
playbook (docs/detection.md Step 7): instrumented production build, publish
via form mode (not the visual editor), sample repeatedly, test the warmed
URL shape. Quick sanity check in production: GET /api/storyblok-cv before and
after a publish — the value must change without a restart.
Development
npm install
npm run dev:prepare # stub build + playground types
npm run dev # playground on localhost:3000
npm run lint # eslint (@nuxt/eslint-config, tooling + stylistic)
npm run test # vitest e2e against a fixture app + mocked Storyblok API
npm run test:types # vue-tsc, server and app contexts separately
npm run prepack # build dist/
npm run release # local: lint + test + build + changelogen + npm publishReleasing
CI and releases run on Buddy and are managed in Buddy (GUI/API), not
from a committed file — same as @towa-digital/storyblok-nuxt-sitemap. Two
pipelines: Test (auto-runs on branch pushes) and Release to npm
(manual/click-to-run from main, publishes the package.json version to
public npm).
Release flow: bump the version + changelog on main (npx changelogen
--release or edit package.json), push, then click Run on the Release to
npm pipeline in Buddy. Publish auth comes from a Buddy FILE variable named
npmrc (npm auth config) on the project or workspace — $npmrc resolves to its
path, copied to .npmrc before publish. Publishing requires the version to be
new (npm rejects re-publishing an existing version).
