diff --git a/frontend/src/composables/useTabQuery.js b/frontend/src/composables/useTabQuery.js
new file mode 100644
index 0000000..57b39ae
--- /dev/null
+++ b/frontend/src/composables/useTabQuery.js
@@ -0,0 +1,30 @@
+import { ref, watch } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+
+// Two-way bind a tab ref with the ?tab= query param: the initial value comes
+// from the URL (when it names a valid tab) else `defaultTab` — a string, or a
+// function for a state-dependent default. Tab changes push to the URL via
+// router.replace (no history spam); URL changes (back/forward) flow back into
+// the ref. Returns { tab, resolve } where resolve() re-applies the URL-or-
+// default rule (callers re-run it when their default inputs change).
+export function useTabQuery(validTabs, defaultTab) {
+ const route = useRoute()
+ const router = useRouter()
+
+ const resolveDefault = () =>
+ (typeof defaultTab === 'function' ? defaultTab() : defaultTab)
+ const resolve = () =>
+ (validTabs.includes(route.query.tab) ? route.query.tab : resolveDefault())
+
+ const tab = ref(resolve())
+
+ watch(tab, (t) => {
+ if (route.query.tab === t) return
+ router.replace({ query: { ...route.query, tab: t } })
+ })
+ watch(() => route.query.tab, (q) => {
+ if (q && validTabs.includes(q) && tab.value !== q) tab.value = q
+ })
+
+ return { tab, resolve }
+}
diff --git a/frontend/src/views/ArtistView.vue b/frontend/src/views/ArtistView.vue
index 876b9d8..0dcf90f 100644
--- a/frontend/src/views/ArtistView.vue
+++ b/frontend/src/views/ArtistView.vue
@@ -39,30 +39,26 @@