nuxt-skew-protection
Tested against nuxt-skew-protection 1.6.2 on Nuxt 4.5.2.
At build time the module stores every build's assets, then copies the chunks of earlier builds back into .output/public.
In the browser it detects a new deploy and tells you when the chunks the tab loaded are gone. Docs: https://nuxtseo.com/docs/skew-protection
Setup
Included in @nuxtjs/seo v6. If the app uses @nuxtjs/seo, configure the skewProtection key and do not add the module again.
Nuxt SEO overrides two defaults: updateStrategy: 'polling' and cookie: false. Set skewProtection: false to remove the module from Nuxt SEO.
Without Nuxt SEO, run npx nuxt module add nuxt-skew-protection.
@nuxtjs/robots v7 and nuxt-site-config v5 are optional peers. Install Nuxt Robots to keep bot traffic off SSE and WebSocket connections.
Build history lives in storage. The default is the fs-lite driver at node_modules/.cache/nuxt-seo/skew-protection, relative to the project root.
If CI starts from a clean checkout, every build is the first build: the build logs No previous versions found in storage and no old chunk survives.
Persist that directory, or configure a shared driver (redis, upstash, cloudflare-kv-binding). On GitHub Actions, give each deploy a new key so the cache saves:
- uses: actions/cache@v4
with:
path: node_modules/.cache/nuxt-seo
key: nuxt-skew-${{ github.sha }}
restore-keys: nuxt-skew-Add a notification. Nothing shows until you render <SkewNotification> or call useSkewProtection().
Automatic behaviour
- Build: stores the assets, restores old chunks into
.output/public, and addsskewProtection.versionsto/_nuxt/builds/latest.json. Cleanup keeps 10 versions for 30 days (maxNumberOfVersions,retentionDays). - Cookie:
__nkpvholds the build id, for 7 days,SameSite=Lax. The server sets it only on requests withsec-fetch-dest: document. Withapp.baseURL: '/app/'the name becomes__nkpv_app. - Server context: every request gets
event.context.skewVersionfrom the cookie. - Service worker:
/_nuxt-skew-sw.jsrecords which chunks the tab loaded. - Update strategy: every platform defaults to native Nuxt
polling, once per hour. Explicitsse,ws, or adapters enable push. The client subscribes when a component using the composable mounts. Native polling needs appManifest and checkOutdatedBuildInterval enabled. - Endpoints:
/__skew/health,/__skew/sse, and/__skew/ws, underbasePath. Withapp.baseURL: '/app/'they sit at/app/__skew/*. A static build has none. - Multi tab: BroadcastChannel shares complete manifests within the app/base path. Web Locks select one push owner without heartbeats. Closing, hiding, or navigating the owner releases it; visible or restored pages resubscribe unless explicitly disconnected. Unsupported APIs and connectionTracking keep per-tab transports. Set multiTab: false for independent detection. Visible subscribers share VERSION hints without connection identifiers. The owner receives other transport messages and sends commands. Every tab receives onAppOutdated updates. Disable sharing or enable connectionTracking for independent commands.
- HTML cache capability: when
bundleAssetsandstorageare set, the module publishesruntimeConfig.htmlCacheCapabilitiesso sibling modules (such as a Cloudflare CDN module) can read how long a cached document safely outlives its build.
Show an update prompt
<SkewNotification> is headless and renders inside <ClientOnly>. Put it in app.vue or a layout.
<template>
<SkewNotification v-slot="{ isCurrentChunksOutdated, dismiss, reload, timeAgo }">
<div v-if="isCurrentChunksOutdated" role="status">
New version released {{ timeAgo }}.
<button @click="reload">
Reload
</button>
<button @click="dismiss">
Not now
</button>
</div>
</SkewNotification>
</template>Pick the slot prop by intent:
isCurrentChunksOutdated: the deploy deleted a chunk this tab loaded. Use it for the prompt.isAppOutdated: any new deploy. On a prerendered page this is alwaysfalse, because the HTML carries a build id from build time.isOpen: either of the two.
dismiss() hides the prompt until the next deploy. reload() calls reloadNuxtApp({ force: true, persistState: true }).
To preview the UI, pass force-open. There is no open prop.
To reload without a prompt, set reloadStrategy:
'immediate': reload when chunks go stale.'idle': reload after 60 seconds without user input, or as soon as the tab is hidden.false: do nothing. Handleskew:chunks-outdatedyourself.
React in code
useSkewProtection() is auto-imported. It returns refs and registers callbacks that the module removes on unmount.
const {
onCurrentChunksOutdated,
onAppOutdated,
isAppOutdated,
isRollback,
clientVersion,
} = useSkewProtection()
onCurrentChunksOutdated(({ invalidatedModules, passedReleases }) => {
// the tab runs deleted code; save state, then reload
})isRollback:truewhen the server version is older than the client version (a deployment rollback). Requires version timestamps inskewProtection.versionsfromlatest.json.simulateUpdate(): dev-only helper that firesskew:chunks-outdatedwith empty arrays so you can test the notification UI without deploying.
If the update was already detected, a callback runs at registration.
useSkewProtection({ lazy: true }) does not connect on mount. Call connect() yourself.
Reject stale clients on the server
The server helpers are not auto-imported. Import them from #skew-protection/server. The nuxt-skew-protection/server alias also works.
import { isClientOutdated } from '#skew-protection/server'
export default defineEventHandler((event) => {
if (isClientOutdated(event)) {
setResponseStatus(event, 409)
return { error: 'Client outdated', requiresReload: true }
}
return { ok: true }
})isClientOutdated is false when the request has no cookie. The same entry exports getClientVersion, getSkewProtectionCookie, and setSkewProtectionCookie.
Cloudflare
cloudflare-modulepolls by default. Workers hold no SSE stream and no WebSocket.- Real time needs
nitro.preset: 'cloudflare-durable'andnitro.experimental.websocket: true. - For KV storage, set
storage: { driver: 'cloudflare-kv-binding' }. The build reads theSKEW_PROTECTIONbinding id fromnitro.cloudflare.wrangler.kv_namespacesorwrangler.json(c)/wrangler.toml. With@nuxthub/core, setstorage.namespaceId, or the build throws. - On
cloudflare-moduleandcloudflare-durable, the module adds your build asset path toassets.run_worker_first. Those requests count as Worker invocations.
Traps
- A headless browser test sees no push updates. If
@nuxtjs/robotsis installed, its bot detection matchesHeadlessChromeand skips the SSE or WebSocket connection. Override the user agent in the test. useActiveConnections()exists only withconnectionTracking: true. It needssseorws, and stats reach only connections that callauthorize()in the Nitro hookskew:authorize-stats. See https://nuxtseo.com/docs/skew-protection/guides/live-connectionssseorwsonnuxt generatefalls back to polling with a warning. Polling uses Nuxtexperimental.checkOutdatedBuildInterval, which defaults to one hour.- Vercel native skew protection turns off asset storage. When
VERCEL_SKEW_PROTECTION_ENABLED=1andVERCEL_DEPLOYMENT_IDare set,bundleAssetsdefaults tofalse. - A route rule that caches HTML with
max-ageand nos-maxagedrops the cookie for that route. The build warns. Uses-maxagefor a CDN, orprivatefor the browser only. - The cookie lasts 7 days. It is not a session cookie. Use that duration in a cookie consent list.
isRollbackrequires version timestamps. It readsskewProtection.versionsfromlatest.json. If storage is not configured orbundleAssetsisfalse, it always returnsfalse.
Version limits
2.x requires Nuxt ^4.6.0 || ^5.0.0 and Node ^22.22.3 || ^24.15.0 || >=26.0.0. These 2.x changes break 1.x setups:
| 1.x | 2.x fix |
|---|---|
| SSE by default on most server presets | Polling on every platform. Set updateStrategy: 'sse', or 'ws' on cloudflare-durable, for push updates and connectionTracking. |
experimental.appManifest: false allowed |
The build throws. Remove the override, or set skewProtection: { enabled: false }. |
| One push connection per tab | Visible tabs share one connection. Set multiTab: false or connectionTracking: true for one connection per tab. |
storage.driver default fs |
The default is fs-lite with the same paths. Set driver: 'fs' to keep the old driver. |
| Robots and Site Config installed with the module | Both are optional peers. Run npx nuxt module add @nuxtjs/robots to keep bot filtering. |
| DevTools panel built in | Install nuxtseo-devtools-host as a dev dependency. |
1.x renamed these. The package binary rewrites them in place: pnpm exec nuxt-skew-protection migrate.
| 0.x | 1.x |
|---|---|
skew-protection:chunks-outdated hook |
skew:chunks-outdated |
isOutdated |
isAppOutdated |
bundlePreviousDeploymentChunks |
bundleAssets |
/_skew/* routes |
/__skew/* |
import { checkForUpdates } from '#skew-protection' |
useSkewProtection().checkForUpdates |
Config
bundleAssets(true): setfalsewhen your CDN already keeps old/_nuxt/files.cookie: setfalseto drop the cookie.isClientOutdatedthen always returnsfalse.basePath(/__skew): the full public endpoint prefix, includingapp.baseURL. Auto-detected; set it only for custom routing.updateStrategy: passpusherAdapter({ key, cluster, appId, secret })fromnuxt-skew-protection/adapters/pusher, orablyAdapter({ key, authUrl })fromnuxt-skew-protection/adapters/ably, for a hosted realtime provider. Installpusher-jsorably. The build validates the config and broadcasts each new build id. For another provider, write one withdefineAdapterfromnuxt-skew-protection/adapters: https://nuxtseo.com/docs/skew-protection/providers/external- Other options: https://nuxtseo.com/docs/skew-protection/api/config
Debug
GET /__skew/healthreturns{ ok, version, uptime }. Compareversionwith the client build id./_nuxt/builds/latest.jsonlists every stored version underskewProtection.versions. One entry after a second deploy means storage did not persist.debug: truelogs detection, service worker, and storage steps.- Nuxt DevTools has a Skew Protection tab. The panel needs
nuxtseo-devtools-hostas a dev dependency. - In dev, call
useSkewProtection().simulateUpdate()from the browser console to fireskew:chunks-outdatedand preview the notification without deploying.