diff --git a/backend/internal/web/embed_on.go b/backend/internal/web/embed_on.go
index 41ce4d48..ffca98a5 100644
--- a/backend/internal/web/embed_on.go
+++ b/backend/internal/web/embed_on.go
@@ -180,7 +180,37 @@ func (s *FrontendServer) injectSettings(settingsJSON []byte) []byte {
// Inject before
headClose := []byte("")
- return bytes.Replace(s.baseHTML, headClose, append(script, headClose...), 1)
+ result := bytes.Replace(s.baseHTML, headClose, append(script, headClose...), 1)
+
+ // Replace
with custom site name so the browser tab shows it immediately
+ result = injectSiteTitle(result, settingsJSON)
+
+ return result
+}
+
+// injectSiteTitle replaces the static in HTML with the configured site name.
+// This ensures the browser tab shows the correct title before JS executes.
+func injectSiteTitle(html, settingsJSON []byte) []byte {
+ var cfg struct {
+ SiteName string `json:"site_name"`
+ }
+ if err := json.Unmarshal(settingsJSON, &cfg); err != nil || cfg.SiteName == "" {
+ return html
+ }
+
+ // Find and replace the existing ...
+ titleStart := bytes.Index(html, []byte(""))
+ titleEnd := bytes.Index(html, []byte(""))
+ if titleStart == -1 || titleEnd == -1 || titleEnd <= titleStart {
+ return html
+ }
+
+ newTitle := []byte("" + cfg.SiteName + " - AI API Gateway")
+ var buf bytes.Buffer
+ buf.Write(html[:titleStart])
+ buf.Write(newTitle)
+ buf.Write(html[titleEnd+len(""):])
+ return buf.Bytes()
}
// replaceNoncePlaceholder replaces the nonce placeholder with actual nonce value
diff --git a/backend/internal/web/embed_test.go b/backend/internal/web/embed_test.go
index f270b624..fd47c4da 100644
--- a/backend/internal/web/embed_test.go
+++ b/backend/internal/web/embed_test.go
@@ -20,6 +20,78 @@ func init() {
gin.SetMode(gin.TestMode)
}
+func TestInjectSiteTitle(t *testing.T) {
+ t.Run("replaces_title_with_site_name", func(t *testing.T) {
+ html := []byte(`Sub2API - AI API Gateway`)
+ settingsJSON := []byte(`{"site_name":"MyCustomSite"}`)
+
+ result := injectSiteTitle(html, settingsJSON)
+
+ assert.Contains(t, string(result), "MyCustomSite - AI API Gateway")
+ assert.NotContains(t, string(result), "Sub2API")
+ })
+
+ t.Run("returns_unchanged_when_site_name_empty", func(t *testing.T) {
+ html := []byte(`Sub2API - AI API Gateway`)
+ settingsJSON := []byte(`{"site_name":""}`)
+
+ result := injectSiteTitle(html, settingsJSON)
+
+ assert.Equal(t, string(html), string(result))
+ })
+
+ t.Run("returns_unchanged_when_site_name_missing", func(t *testing.T) {
+ html := []byte(`Sub2API - AI API Gateway`)
+ settingsJSON := []byte(`{"other_field":"value"}`)
+
+ result := injectSiteTitle(html, settingsJSON)
+
+ assert.Equal(t, string(html), string(result))
+ })
+
+ t.Run("returns_unchanged_when_invalid_json", func(t *testing.T) {
+ html := []byte(`Sub2API - AI API Gateway`)
+ settingsJSON := []byte(`{invalid json}`)
+
+ result := injectSiteTitle(html, settingsJSON)
+
+ assert.Equal(t, string(html), string(result))
+ })
+
+ t.Run("returns_unchanged_when_no_title_tag", func(t *testing.T) {
+ html := []byte(``)
+ settingsJSON := []byte(`{"site_name":"MyCustomSite"}`)
+
+ result := injectSiteTitle(html, settingsJSON)
+
+ assert.Equal(t, string(html), string(result))
+ })
+
+ t.Run("returns_unchanged_when_title_has_attributes", func(t *testing.T) {
+ // The function looks for "" literally, so attributes are not supported
+ // This is acceptable since index.html uses plain without attributes
+ html := []byte(`Sub2API`)
+ settingsJSON := []byte(`{"site_name":"NewSite"}`)
+
+ result := injectSiteTitle(html, settingsJSON)
+
+ // Should return unchanged since with attributes is not matched
+ assert.Equal(t, string(html), string(result))
+ })
+
+ t.Run("preserves_rest_of_html", func(t *testing.T) {
+ html := []byte(`Sub2API`)
+ settingsJSON := []byte(`{"site_name":"TestSite"}`)
+
+ result := injectSiteTitle(html, settingsJSON)
+
+ assert.Contains(t, string(result), ``)
+ assert.Contains(t, string(result), ``)
+ assert.Contains(t, string(result), ``)
+ assert.Contains(t, string(result), "TestSite - AI API Gateway")
+ })
+}
+
func TestReplaceNoncePlaceholder(t *testing.T) {
t.Run("replaces_single_placeholder", func(t *testing.T) {
html := []byte(``)
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index 4fc6a7c8..7485aa1a 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -3,6 +3,7 @@ import { RouterView, useRouter, useRoute } from 'vue-router'
import { onMounted, onBeforeUnmount, watch } from 'vue'
import Toast from '@/components/common/Toast.vue'
import NavigationProgress from '@/components/common/NavigationProgress.vue'
+import { resolveDocumentTitle } from '@/router/title'
import AnnouncementPopup from '@/components/common/AnnouncementPopup.vue'
import { useAppStore, useAuthStore, useSubscriptionStore, useAnnouncementStore } from '@/stores'
import { getSetupStatus } from '@/api/setup'
@@ -104,6 +105,9 @@ onMounted(async () => {
// Load public settings into appStore (will be cached for other components)
await appStore.fetchPublicSettings()
+
+ // Re-resolve document title now that siteName is available
+ document.title = resolveDocumentTitle(route.meta.title, appStore.siteName, route.meta.titleKey as string)
})