Your Next.js app deployed successfully. Users are still running yesterday's code. They click a button and get a cryptic error. This is version skew.
Version skew occurs when a user's browser runs an outdated frontend while your backend expects the latest version. In Next.js, this typically happens when:
- Users keep tabs open for hours or days
- A deployment completes while users are mid-session
- Cached JavaScript bundles don't match the server's expectations
The result: broken Server Actions, failed API calls, hydration mismatches, and confused users.
This guide covers every approach to fixing it, whether you're on Vercel, self-hosting, or somewhere in between. Tested with Next.js 16, React 19, and TanStack Query v5.
Understanding How Version Skew Breaks Your App
When you deploy a new version of your Next.js app, the server starts serving new HTML, new API responses, and expects new request formats. But users with open tabs still have the old JavaScript bundle cached in their browser.
What breaks:

Common symptoms:
- Server Actions fail with encryption errors (the encryption key changed between builds)
- API responses have different shapes than the client expects
- Dynamic imports fail because chunk hashes changed
- Hydration errors from mismatched server/client HTML
- Form submissions silently fail or return unexpected errors
The core problem: Next.js generates unique identifiers for each build. When client and server identifiers don't match, things break.
The BUILD_ID and Deployment ID
Next.js generates a BUILD_ID for each build, stored in .next/BUILD_ID. This ID changes with every deployment.
Starting with Next.js 14.1.4, you can also set a custom deploymentId in your config:
// next.config.tsconst config = { experimental: { deploymentId: process.env.VERCEL_DEPLOYMENT_ID || process.env.CF_PAGES_COMMIT_SHA || 'local-dev' }}This ID gets sent with requests, allowing servers to detect version mismatches.
Server Actions Have It Worse
Server Actions encrypt their arguments using a key derived from the build. When the build changes, the encryption key changes. Old clients send payloads encrypted with the old key, and the new server can't decrypt them.
Unlike regular API calls that might return a usable error, Server Actions fail cryptically. The client can't automatically recover because it doesn't know why the action failed.
Workaround: Set NEXT_SERVER_ACTIONS_ENCRYPTION_KEY to a stable value across deployments. This ensures the encryption key survives deployments, but you're trading some security for stability. Only do this if you understand the implications.
Platform-Native Solutions
If you're on a major hosting platform, they probably handle this for you. Here's what each offers:
Vercel Skew Protection
Vercel's Skew Protection (available on Pro and Enterprise plans) routes requests to the deployment that served the original HTML. No code changes required.
How it works:
- Vercel tags each response with the deployment ID
- Client requests include this ID in headers
- Vercel routes requests to the matching deployment
- Old deployments stay warm for a configurable duration
Enable it:
# In your Vercel dashboard, or via CLI:vercel env add VERCEL_SKEW_PROTECTION_ENABLED 1When to use Vercel's solution: You're on Pro/Enterprise and want zero-code protection. It handles Server Actions, API routes, and RSC payloads automatically.
When to add custom handling anyway: You want to prompt users to refresh (better UX than silent routing to old code), or you're on the free tier.
AWS Amplify Skew Protection
AWS Amplify added skew protection in 2024. It works similarly to Vercel's approach.
Enable it in your Amplify console under App settings > Build settings, or via amplify.yml:
# amplify.ymlbuild: commands: - npm run buildfrontend: phases: build: commands: - npm run build artifacts: baseDirectory: .next files: - '**/*' cache: paths: - node_modules/**/*The AWS_AMPLIFY_DEPLOYMENT_ID environment variable is automatically set and used for routing.
Netlify
Netlify's Next.js runtime includes skew protection when you enable it:
# Set in Netlify dashboard or netlify.tomlNETLIFY_NEXT_SKEW_PROTECTION=trueNetlify uses COMMIT_REF for version identification.
Cloudflare Pages with OpenNext
Cloudflare Pages doesn't have native skew protection, but OpenNext (the adapter for running Next.js on Cloudflare) supports it.
In your open-next.config.ts:
export default { dangerous: { enableSkewProtection: true }}This uses CF_PAGES_COMMIT_SHA to identify deployments.
When Platform Solutions Aren't Enough
Platform skew protection silently routes old clients to old deployments. This prevents errors, but users stay on stale code until they manually refresh.
For many apps, this is fine. But if you want users on the latest version (security patches, critical bug fixes, new features), you need to prompt them to reload.
That's where custom version checking comes in.
Custom Implementation for Self-Hosting
If you're self-hosting on Railway, Render, Fly.io, Docker, or any platform without native skew protection, you need to build your own solution.
The approach has three parts:
- A version endpoint that returns the current deployment identifier
- A client-side hook that polls this endpoint
- A UI component that prompts users to reload
Step 1: The Version Endpoint
Create a route that returns a stable identifier for the current deployment:
// app/api/version/route.tsexport const dynamic = 'force-static';const BUILD_TIME = process.env.NODE_ENV === 'development' ? 'dev' : new Date().toISOString();export const GET = () => { return new Response(BUILD_TIME, { headers: { 'content-type': 'text/plain' }, });};Why force-static? This endpoint gets cached at build time. The response stays constant for the lifetime of the deployment, which is exactly what we want.
Why build time instead of git hash? Build time is always available, works in every environment, and changes with every deployment. Git hashes require extra setup in CI and aren't available in all hosting environments.
If you prefer git hashes (useful for debugging), here's an alternative:
// app/api/version/route.tsexport const dynamic = 'force-static';const KNOWN_GIT_ENV_VARS = [ 'VERCEL_GIT_COMMIT_SHA', 'CF_PAGES_COMMIT_SHA', 'RAILWAY_GIT_COMMIT_SHA', 'RENDER_GIT_COMMIT', 'COMMIT_REF', // Netlify 'GIT_HASH', // Custom];function getVersion(): string { // Check platform-specific env vars for (const envVar of KNOWN_GIT_ENV_VARS) { if (process.env[envVar]) { return process.env[envVar]; } } // Fallback to build timestamp return new Date().toISOString();}const VERSION = getVersion();export const GET = () => { return new Response(VERSION, { headers: { 'content-type': 'text/plain' }, });};Step 2: Version Checker Hook
Use TanStack Query to poll the version endpoint:
// hooks/use-version-updater.ts'use client';import { useQuery } from '@tanstack/react-query';let currentVersion: string | null = null;const POLL_INTERVAL_MS = 60_000; // 60 secondsexport function useVersionUpdater() { return useQuery({ queryKey: ['version-updater'], staleTime: POLL_INTERVAL_MS / 2, gcTime: POLL_INTERVAL_MS, refetchInterval: POLL_INTERVAL_MS, refetchIntervalInBackground: true, initialData: null, queryFn: async () => { const response = await fetch('/api/version'); const latestVersion = await response.text(); const previousVersion = currentVersion; currentVersion = latestVersion; const didChange = previousVersion !== null && latestVersion !== previousVersion; return { latestVersion, previousVersion, didChange, }; }, });}Why TanStack Query? It handles caching, background refetching, and error states. You could use setInterval with useState, but Query handles edge cases you'd otherwise need to code yourself.
Polling interval: 60 seconds is a reasonable default. Shorter intervals detect changes faster but increase server load. For most apps, 30-120 seconds works well. You can make this configurable:
const POLL_INTERVAL_MS = Number(process.env.NEXT_PUBLIC_VERSION_POLL_INTERVAL_SECONDS || 60) * 1000;Step 3: Update Prompt Component
Show users a dialog when a new version is available:
// components/version-updater.tsx'use client';import { useEffect, useState } from 'react';import { useVersionUpdater } from '@/hooks/use-version-updater';import { AlertDialog, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,} from '@/components/ui/alert-dialog';import { Button } from '@/components/ui/button';export function VersionUpdater() { const { data } = useVersionUpdater(); const [dismissed, setDismissed] = useState(false); const [showDialog, setShowDialog] = useState(false); useEffect(() => { if (data?.didChange && !dismissed) { setShowDialog(true); } }, [data?.didChange, dismissed]); if (!data?.didChange || dismissed) { return null; } return ( <AlertDialog open={showDialog} onOpenChange={setShowDialog}> <AlertDialogContent> <AlertDialogHeader> <AlertDialogTitle> Update Available </AlertDialogTitle> <AlertDialogDescription> A new version of the app is available. Reload to get the latest features and fixes. </AlertDialogDescription> </AlertDialogHeader> <AlertDialogFooter> <Button variant="outline" onClick={() => { setShowDialog(false); setDismissed(true); }} > Later </Button> <Button onClick={() => window.location.reload()}> Reload Now </Button> </AlertDialogFooter> </AlertDialogContent> </AlertDialog> );}Add this component to your root layout:
// app/layout.tsximport { VersionUpdater } from '@/components/version-updater';export default function RootLayout({ children }) { return ( <html> <body> {children} <VersionUpdater /> </body> </html> );}
Platform Environment Variables Reference
When implementing custom version checking, use these platform-specific variables:
| Platform | Deployment ID | Git Commit SHA |
|---|---|---|
| Vercel | VERCEL_DEPLOYMENT_ID | VERCEL_GIT_COMMIT_SHA |
| Cloudflare Pages | N/A | CF_PAGES_COMMIT_SHA |
| AWS Amplify | AWS_AMPLIFY_DEPLOYMENT_ID | N/A |
| Netlify | N/A | COMMIT_REF |
| Railway | N/A | RAILWAY_GIT_COMMIT_SHA |
| Render | N/A | RENDER_GIT_COMMIT |
| Fly.io | N/A | Set custom in fly.toml |
| Self-hosted | Custom GIT_HASH | git rev-parse HEAD in CI |
For self-hosted deployments, set the version in your CI/CD:
# In your build script or CI configexport GIT_HASH=$(git rev-parse --short HEAD)docker build --build-arg GIT_HASH=$GIT_HASH .Production Considerations
CDN Caching
If you're using a CDN, ensure your version endpoint isn't cached longer than your deployment frequency:
export const GET = () => { return new Response(VERSION, { headers: { 'content-type': 'text/plain', 'cache-control': 'public, max-age=60, s-maxage=60', }, });};With force-static, Next.js caches the response at build time. The CDN should respect this and serve the cached version until the next deployment.
Middleware Interactions
If you have middleware that modifies responses or redirects, ensure it doesn't interfere with the version endpoint:
// middleware.tsexport function middleware(request: NextRequest) { // Skip version endpoint if (request.nextUrl.pathname === '/api/version') { return NextResponse.next(); } // Your other middleware logic}Don't Poll Too Aggressively
More frequent polling means faster detection but more server load. For most apps:
- 30 seconds: High-traffic apps with frequent deployments
- 60 seconds: Default, good balance
- 120 seconds: Low-traffic apps, infrequent deployments
Remember: the version endpoint is static and cacheable, so the actual server load is minimal. The main cost is client-side network requests.
Graceful Degradation
Handle cases where the version endpoint fails:
queryFn: async () => { try { const response = await fetch('/api/version'); if (!response.ok) { // Don't trigger false positives on errors return { latestVersion: currentVersion, didChange: false }; } // ... rest of logic } catch { // Network error, skip this check return { latestVersion: currentVersion, didChange: false }; }},MakerKit's Built-In Solution
MakerKit includes version skew protection out of the box. The implementation follows the patterns above, with a few refinements:
- Uses TanStack Query for efficient polling
- Configurable interval via
NEXT_PUBLIC_VERSION_UPDATER_REFETCH_INTERVAL_SECONDS - Localized dialog text via i18next
- Accessible dialog from Shadcn UI
If you're building a SaaS with MakerKit, you get this for free. Enable it by adding the VersionUpdater component to your layout (it's already there in the starter).
Quick Recommendation
Version skew protection is best for:
- Apps where users keep tabs open for long periods
- Apps with frequent deployments
- Apps using Server Actions heavily
- Self-hosted Next.js deployments
Skip version skew protection if:
- Your app has very short sessions (users close tabs quickly)
- You deploy rarely (weekly or less)
- You're on Vercel Pro/Enterprise and don't need user prompts
Our pick: If you're on Vercel Pro, enable their native Skew Protection and optionally add a version checker for user prompts. If you're self-hosting, implement the custom solution shown above. The time investment is minimal and prevents a category of confusing production bugs.
Frequently Asked Questions
Does Next.js automatically reload when a new version is deployed?
What's the difference between Vercel Skew Protection and a custom version checker?
How do I handle version skew with Server Actions?
Can I use a git commit hash instead of build timestamp?
How often should the version checker poll?
Does version skew protection work with edge functions?
What happens if the version check fails?
Should I force users to reload or let them dismiss?
What's Next
Version skew is one piece of the deployment puzzle. For a deeper dive into hosting options:
- Best Next.js Hosting Providers compares platforms on features, pricing, and DX
- Next.js Server Actions Guide covers Server Actions patterns including error handling