diff --git a/app/api/version/route.ts b/app/api/version/route.ts
new file mode 100644
index 00000000..1fbc8461
--- /dev/null
+++ b/app/api/version/route.ts
@@ -0,0 +1,20 @@
+import { NextResponse } from 'next/server'
+
+/**
+ * Public, unauthenticated build-version probe.
+ *
+ * The client compares the id it was built with (NEXT_PUBLIC_BUILD_ID, inlined
+ * into its JS bundle at build time) against this value, which is read at
+ * request time from the currently running deployment. A mismatch means a newer
+ * deploy is live and the open tab is running a stale bundle, so the client
+ * offers a reload (see components/system/DeployReloadPrompt).
+ *
+ * force-dynamic + no-store so it always reflects the live deployment rather
+ * than a value baked in at build.
+ */
+export const dynamic = 'force-dynamic'
+
+export function GET() {
+ const id = process.env.VERCEL_GIT_COMMIT_SHA ?? ''
+ return NextResponse.json({ id }, { headers: { 'Cache-Control': 'no-store' } })
+}
diff --git a/app/layout.tsx b/app/layout.tsx
index 203301b9..831eceb9 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -5,6 +5,7 @@ import Script from "next/script";
import { NextIntlClientProvider } from "next-intl";
import { getLocale, getMessages } from "next-intl/server";
import { Toaster } from "@/components/ui/toaster";
+import { DeployReloadPrompt } from "@/components/system/DeployReloadPrompt";
import { ThemeProvider } from "@/components/theme-provider";
import { RecaptLoader } from "@/components/RecaptLoader";
import { RecaptHideWidget } from "@/components/RecaptHideWidget";
@@ -86,6 +87,7 @@ export default async function RootLayout({
>
{children}
+
diff --git a/components/system/DeployReloadPrompt.tsx b/components/system/DeployReloadPrompt.tsx
new file mode 100644
index 00000000..28cde865
--- /dev/null
+++ b/components/system/DeployReloadPrompt.tsx
@@ -0,0 +1,65 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import { useTranslations } from 'next-intl'
+import { Button } from '@/components/ui/button'
+
+// Inlined at build time from next.config's `env` (the deploy's commit SHA on
+// Vercel; empty in dev / self-hosted, which turns the check off).
+const BUILD_ID = process.env.NEXT_PUBLIC_BUILD_ID || ''
+
+/**
+ * Detects when a newer deploy is live while this tab is still running an old JS
+ * bundle, and offers a one-click reload. This is why a just-shipped change can
+ * appear "missing" in a long-open tab until the whole app is reloaded.
+ *
+ * Compares the build id baked into this bundle against /api/version (the
+ * running deployment's id), re-checking when the tab regains focus plus a slow
+ * interval backstop. No-op when no build id is set.
+ */
+export function DeployReloadPrompt() {
+ const t = useTranslations('common')
+ const [stale, setStale] = useState(false)
+
+ useEffect(() => {
+ if (!BUILD_ID || stale) return
+
+ let cancelled = false
+ async function check() {
+ try {
+ const res = await fetch('/api/version', { cache: 'no-store' })
+ if (!res.ok) return
+ const { id } = await res.json()
+ if (!cancelled && id && id !== BUILD_ID) setStale(true)
+ } catch {
+ // Transient network error: ignore, the next trigger retries.
+ }
+ }
+
+ function onVisible() {
+ if (document.visibilityState === 'visible') check()
+ }
+
+ check()
+ document.addEventListener('visibilitychange', onVisible)
+ const interval = setInterval(check, 30 * 60 * 1000) // 30 min backstop
+ return () => {
+ cancelled = true
+ document.removeEventListener('visibilitychange', onVisible)
+ clearInterval(interval)
+ }
+ }, [stale])
+
+ if (!stale) return null
+
+ return (
+
+
+ {t('update_available')}
+
+
+
+ )
+}
diff --git a/messages/en.json b/messages/en.json
index 06106062..dbc07fa8 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -1,5 +1,7 @@
{
"common": {
+ "update_available": "A new version is available.",
+ "reload": "Reload",
"save": "Save",
"saving": "Saving...",
"cancel": "Cancel",
diff --git a/messages/sv.json b/messages/sv.json
index 8c480844..5abf1f1c 100644
--- a/messages/sv.json
+++ b/messages/sv.json
@@ -1,5 +1,7 @@
{
"common": {
+ "update_available": "En ny version finns tillgänglig.",
+ "reload": "Ladda om",
"save": "Spara",
"saving": "Sparar...",
"cancel": "Avbryt",
diff --git a/next.config.ts b/next.config.ts
index 033a1be1..4723feaa 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -52,6 +52,13 @@ const cspDirectives = [
const nextConfig: NextConfig = {
output: 'standalone',
+ // Build id inlined into the client bundle so a running tab can tell when a
+ // newer deploy is live (see components/system/DeployReloadPrompt). On Vercel
+ // this is the commit SHA; empty elsewhere (dev / self-hosted), which disables
+ // the check. The /api/version route reads the same var at runtime to compare.
+ env: {
+ NEXT_PUBLIC_BUILD_ID: process.env.VERCEL_GIT_COMMIT_SHA ?? '',
+ },
// Multiple lockfiles exist above this project (e.g. a parent yarn.lock),
// which makes Turbopack infer the wrong workspace root. Pin it explicitly.
turbopack: {