diff --git a/next.config.ts b/next.config.ts
index 3475016..228aacf 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -2,6 +2,9 @@ import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
output: 'standalone',
+ env: {
+ NEXT_PUBLIC_BUILD_ID: Date.now().toString(),
+ },
serverExternalPackages: ['@prisma/client', 'minio'],
typescript: {
// We run tsc --noEmit separately before each push
diff --git a/src/app/api/version/route.ts b/src/app/api/version/route.ts
new file mode 100644
index 0000000..53c4c87
--- /dev/null
+++ b/src/app/api/version/route.ts
@@ -0,0 +1,7 @@
+import { NextResponse } from 'next/server'
+
+export const dynamic = 'force-static'
+
+export function GET() {
+ return NextResponse.json({ buildId: process.env.NEXT_PUBLIC_BUILD_ID })
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 5bdfdad..6cdf80f 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -3,6 +3,7 @@ import './globals.css'
import { Providers } from './providers'
import { Toaster } from 'sonner'
import { ImpersonationBanner } from '@/components/shared/impersonation-banner'
+import { VersionGuard } from '@/components/shared/version-guard'
export const metadata: Metadata = {
title: {
@@ -24,6 +25,7 @@ export default function RootLayout({
+
{children}
diff --git a/src/components/shared/version-guard.tsx b/src/components/shared/version-guard.tsx
new file mode 100644
index 0000000..a5ca618
--- /dev/null
+++ b/src/components/shared/version-guard.tsx
@@ -0,0 +1,47 @@
+'use client'
+
+import { useEffect, useRef } from 'react'
+import { toast } from 'sonner'
+
+const CLIENT_BUILD_ID = process.env.NEXT_PUBLIC_BUILD_ID
+
+export function VersionGuard() {
+ const notified = useRef(false)
+
+ useEffect(() => {
+ async function checkVersion() {
+ if (notified.current) return
+ try {
+ const res = await fetch('/api/version', { cache: 'no-store' })
+ if (!res.ok) return
+ const { buildId } = await res.json()
+ if (buildId && CLIENT_BUILD_ID && buildId !== CLIENT_BUILD_ID) {
+ notified.current = true
+ toast('A new version is available', {
+ description: 'Refresh to get the latest updates.',
+ duration: Infinity,
+ action: {
+ label: 'Refresh',
+ onClick: () => window.location.reload(),
+ },
+ })
+ }
+ } catch {
+ // Network error — ignore
+ }
+ }
+
+ // Check on tab focus (covers users returning to stale tabs)
+ window.addEventListener('focus', checkVersion)
+
+ // Also check every 5 minutes for long-lived tabs
+ const interval = setInterval(checkVersion, 5 * 60 * 1000)
+
+ return () => {
+ window.removeEventListener('focus', checkVersion)
+ clearInterval(interval)
+ }
+ }, [])
+
+ return null
+}