diff --git a/web/src/app/RootLayout.tsx b/web/src/app/RootLayout.tsx
new file mode 100644
index 00000000..444de4b4
--- /dev/null
+++ b/web/src/app/RootLayout.tsx
@@ -0,0 +1,9 @@
+import { Outlet } from 'react-router-dom';
+import { useDocumentTitle } from '@/hooks/useDocumentTitle';
+
+// Top-level route layout: drives the dynamic document title from the active
+// route and renders the matched child route via .
+export default function RootLayout() {
+ useDocumentTitle();
+ return ;
+}
diff --git a/web/src/hooks/useDocumentTitle.ts b/web/src/hooks/useDocumentTitle.ts
new file mode 100644
index 00000000..bc54e320
--- /dev/null
+++ b/web/src/hooks/useDocumentTitle.ts
@@ -0,0 +1,59 @@
+import { useEffect } from 'react';
+import { useLocation } from 'react-router-dom';
+import { useTranslation } from 'react-i18next';
+
+const APP_NAME = 'LangBot';
+
+// Map a route path to the i18n key used for its document title. Detail routes
+// reuse the section key (e.g. /home/bots and any future /home/bots/:id both
+// resolve to bots.title). Reuses existing translation keys so titles stay in
+// sync with the sidebar and page headers across all locales.
+const ROUTE_TITLE_KEYS: { match: (path: string) => boolean; key: string }[] = [
+ { match: (p) => p === '/login', key: 'common.login' },
+ { match: (p) => p === '/register', key: 'register.title' },
+ { match: (p) => p === '/reset-password', key: 'resetPassword.title' },
+ { match: (p) => p === '/wizard', key: 'sidebar.quickStart' },
+ { match: (p) => p.startsWith('/home/monitoring'), key: 'monitoring.title' },
+ { match: (p) => p.startsWith('/home/bots'), key: 'bots.title' },
+ { match: (p) => p.startsWith('/home/pipelines'), key: 'pipelines.title' },
+ {
+ match: (p) => p.startsWith('/home/add-extension'),
+ key: 'sidebar.addExtension',
+ },
+ { match: (p) => p.startsWith('/home/extensions'), key: 'plugins.title' },
+ { match: (p) => p.startsWith('/home/mcp'), key: 'mcp.title' },
+ { match: (p) => p.startsWith('/home/knowledge'), key: 'knowledge.title' },
+ { match: (p) => p.startsWith('/home/skills'), key: 'skills.title' },
+ {
+ match: (p) => p.startsWith('/home/plugin-pages'),
+ key: 'sidebar.pluginPages',
+ },
+ // /home (and anything else under it) falls back to the dashboard.
+ { match: (p) => p.startsWith('/home'), key: 'monitoring.title' },
+];
+
+/**
+ * Keeps document.title in sync with the current route, formatted as
+ * " · LangBot". On routes with no specific mapping (or before i18n is
+ * ready) it falls back to the bare app name. Re-runs on navigation and on
+ * language change so the title is always localized.
+ */
+export function useDocumentTitle() {
+ const { pathname } = useLocation();
+ const { t, i18n } = useTranslation();
+
+ useEffect(() => {
+ const entry = ROUTE_TITLE_KEYS.find((e) => e.match(pathname));
+ if (!entry) {
+ document.title = APP_NAME;
+ return;
+ }
+ const pageName = t(entry.key);
+ // Guard against an unresolved key (returns the key itself) leaking into the
+ // title; fall back to the bare app name in that case.
+ document.title =
+ pageName && pageName !== entry.key
+ ? `${pageName} · ${APP_NAME}`
+ : APP_NAME;
+ }, [pathname, t, i18n.language]);
+}
diff --git a/web/src/router.tsx b/web/src/router.tsx
index 28bc1284..2f7061bc 100644
--- a/web/src/router.tsx
+++ b/web/src/router.tsx
@@ -25,11 +25,13 @@ import SkillsPage from '@/app/home/skills/page';
import ErrorPage from '@/components/ErrorPage';
import BackendUnavailablePage from '@/components/BackendUnavailablePage';
import PluginPagesPage from '@/app/home/plugin-pages/page';
+import RootLayout from '@/app/RootLayout';
const Loading = () => Loading...
;
export const router = createBrowserRouter([
{
+ element: ,
errorElement: ,
children: [
{