@towa-digital/storyblok-nuxt-cv

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.
3,352
Install
npm install @towa-digital/storyblok-nuxt-cv
Latest Version:0.5.2
Vue:3
License:MIT
Last Updated:Oct 22, 2026
Links: npm
Maintainer: mattcrn

@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 cv to every page render's event.context; an app plugin (compiled into the same bundle as the render client) pins it via useStoryblokApi().setCacheVersion(cv) before content is fetched, and forwards it to the browser through the payload.
  • The stored cv is 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-client v5 → v7 and @storyblok/nuxt 5 → 9: the server side never imports the client (raw cdn/spaces/me fetch), the app side only uses useStoryblokApi/setCacheVersion, which are stable across all those majors. Keeps Storyblok's cv-keyed CDN caching intact (unlike cache: { 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 (see below) webhook
Cloudflare / Vercel Edge / Netlify (isolates) default ttl (seconds, e.g. 60) — a webhook can't reach every isolate

Redis mounts: make the driver fail fast

A store read happens on every page render. Since 0.5.1 a read failure can't break the render — the module falls back to the cv the process fetched itself — but ioredis still retries 20 times per request by default, so an unreachable Redis adds seconds of latency per render before that fallback runs. Configure it to give up immediately, and set ttl so each instance stays fresh on its own if the store is gone:

nitro: {
  storage: {
    'storyblok-cv': {
      driver: 'redis',
      host: process.env.REDIS_HOST,
      maxRetriesPerRequest: 1,   // default 20 — fail fast, don't stall renders
      enableOfflineQueue: false, // reject while disconnected instead of queueing
    },
  },
},
storyblokCv: { ttl: 60 },

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.

If you configure the fs mount yourself instead
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 cv doesn't invalidate HTML already cached by routeRules: { 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's docs/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's cv-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 publish

Releasing

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).

Related Packages

@towa-digital/storyblok-nuxt-sitemap

Storyblok Nuxt Sitemap module

4,225

Version History

Version Released Vue License
0.5.2 ^3.0 MIT
0.5.1 ^3.0 MIT
0.5.0 ^3.0 MIT
0.4.0 ^3.0 MIT
0.3.0 ^3.0 MIT