Next.js Version Skew Protection: The Complete 2026 Guide

Learn how to fix version skew in Next.js. Covers Vercel, Netlify, AWS Amplify, Cloudflare, and custom implementations with ready-to-use code. Updated for Next.js 16.

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:

Flowchart showing how version skew causes API errors when old client code calls new server code

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.ts
const 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:

  1. Vercel tags each response with the deployment ID
  2. Client requests include this ID in headers
  3. Vercel routes requests to the matching deployment
  4. Old deployments stay warm for a configurable duration

Enable it:

# In your Vercel dashboard, or via CLI:
vercel env add VERCEL_SKEW_PROTECTION_ENABLED 1

When 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.yml
build:
commands:
- npm run build
frontend:
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.toml
NETLIFY_NEXT_SKEW_PROTECTION=true

Netlify 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:

  1. A version endpoint that returns the current deployment identifier
  2. A client-side hook that polls this endpoint
  3. 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.ts
export 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.ts
export 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 seconds
export 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.tsx
import { VersionUpdater } from '@/components/version-updater';
export default function RootLayout({ children }) {
return (
<html>
<body>
{children}
<VersionUpdater />
</body>
</html>
);
}
Screenshot of the version update dialog prompting users to reload

Platform Environment Variables Reference

When implementing custom version checking, use these platform-specific variables:

PlatformDeployment IDGit Commit SHA
VercelVERCEL_DEPLOYMENT_IDVERCEL_GIT_COMMIT_SHA
Cloudflare PagesN/ACF_PAGES_COMMIT_SHA
AWS AmplifyAWS_AMPLIFY_DEPLOYMENT_IDN/A
NetlifyN/ACOMMIT_REF
RailwayN/ARAILWAY_GIT_COMMIT_SHA
RenderN/ARENDER_GIT_COMMIT
Fly.ioN/ASet custom in fly.toml
Self-hostedCustom GIT_HASHgit rev-parse HEAD in CI

For self-hosted deployments, set the version in your CI/CD:

# In your build script or CI config
export 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.ts
export 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?
No. Next.js does not automatically reload the page when you deploy a new version. Users with open tabs continue running the old code until they manually refresh. This is why version skew protection matters.
What's the difference between Vercel Skew Protection and a custom version checker?
Vercel Skew Protection silently routes old clients to old deployments, preventing errors. A custom version checker prompts users to reload, getting them on the latest code. You can use both together.
How do I handle version skew with Server Actions?
Server Actions use build-specific encryption keys. When the build changes, old clients can't call new Server Actions. Either use platform skew protection to route to old deployments, or prompt users to reload before their actions fail.
Can I use a git commit hash instead of build timestamp?
Yes. Use platform-specific environment variables like VERCEL_GIT_COMMIT_SHA or CF_PAGES_COMMIT_SHA. Build timestamps work universally but git hashes are more useful for debugging which version users are on.
How often should the version checker poll?
60 seconds is a good default. More frequent (30s) catches updates faster. Less frequent (120s) reduces network requests. The endpoint is static, so server load is minimal either way.
Does version skew protection work with edge functions?
Yes, but the version endpoint should run in the Node.js runtime (not edge) if you're using process-based version detection. With force-static, this doesn't matter since the response is computed at build time.
What happens if the version check fails?
A well-implemented version checker ignores failures and continues with the current version. Don't trigger false positives (prompting users to reload) when the check itself fails due to network issues.
Should I force users to reload or let them dismiss?
Let them dismiss. Forcing reloads interrupts users mid-task, which is worse than running slightly old code. The exception: security-critical updates where you genuinely need to force a reload.

What's Next

Version skew is one piece of the deployment puzzle. For a deeper dive into hosting options: