nuxt-skew-protection
Tested against nuxt-skew-protection 1.5.5 on Nuxt 4.5.2 (requires Nuxt >=4.0.0).
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
Add nuxt-skew-protection to modules. @nuxtjs/robots is a required peer. The module installs it as a module dependency, so do not add it to modules a second time.
Build history lives in storage. The default is the fs 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: static output and Cloudflare Workers use
polling,cloudflare-durableusesws, and everything else usessse. The client opens the connection when a component that uses the composable mounts. - Endpoints:
/__skew/health,/__skew/sse, and/__skew/ws, underbasePath. Withapp.baseURL: '/app/'they sit at/app/__skew/*. A static build has none. - Multi tab: a
BroadcastChannelshares a detected deploy across tabs. SetmultiTab: falseto turn it off.
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, clientVersion } = useSkewProtection()
onCurrentChunksOutdated(({ invalidatedModules, passedReleases }) => {
// the tab runs deleted code; save state, then reload
})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 nuxt-skew-protection/server:
import { isClientOutdated } from 'nuxt-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 updates. Bot detection from
@nuxtjs/robotsmatchesHeadlessChromeand 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.
Version limits
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.