Routing
SkillDev toolsEsposter routing conventions — declarative links via NuxtLink/NuxtInvisibleLink or Vuetify :to (raw <a> lint-banned), navigateTo for imperative navigation (always awaited or returned — never a floating statement), useRoute() lint-banned in favour of useRouter().currentRoute (and why the ban is total, not just for reactive reads), where navigation state lives (url for what the page shows, history-entry state for how the visitor got here, localStorage for preferences — written once in a router.afterEach hook, never per link), route-synced tabs with useEnumRouteQuery, and definePageMeta validate + key for optional/nested segments. Apply when adding links, navigating in code, reading route params/query, syncing tabs to the URL, or writing pages with dynamic or optional route segments.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Routing skill
What this skill tells your AI
The instructions your AI receives, as published by esposter/esposter in .agents/skills/routing/SKILL.md and read by ahel’s review.
Links — NuxtLink / NuxtInvisibleLink or :to, Never a Raw <a>
Declarative links use a Nuxt-native link component or a component's :to prop — a plain destination keeps real anchor semantics (cmd/ctrl/middle-click opens a new tab).
- Internal:
<NuxtLink :to>, or<NuxtInvisibleLink :to>when the link should inherit surrounding styling. - External:
<NuxtLink :to external target="_blank">. - In-page anchor:
<NuxtInvisibleLink :to="{ hash }">(aNuxtLinkclone that strips default link styling). - A link-styled control with no destination is a
<span text-info underline cursor-pointer>, not an anchor. - Vuetify components (
v-btn,v-card,v-list-item,v-tab,v-chip,StyledButton, …) with a plain destination take:todirectly. Reserve@click="navigateTo(...)"for actions that run logic before navigating or compute the target at click time. Route targets still come fromRoutePath, never string-built.
The raw-<a> ban is enforced by packages/configuration/eslint/overrides/vueRules.js via vue/no-restricted-html-elements. Full standard: apps/web/content/docs/architecture/navigation.md.
Imperative Navigation — navigateTo
navigateTo(target, options) is the imperative form: post-mutation redirects, form submits, route guards, and dynamic-only targets with no element to hang :to on (search submit, v-data-table @click:row).
router.push is lint-enforced against (vue/no-restricted-syntax, same file) — use navigateTo(target, { replace: true }). A query-only router.replace({ query }) is not navigation and is fine.
Always await (or return) navigateTo — it is async, and a floating statement-position call is a violation: the promise escapes Vue's async error handling and code after it runs before navigation settles.
- Multi-statement handler or script code →
asyncfunction withawait navigateTo(...). - Middleware →
return navigateTo(...). - A single-expression inline handler (
@click="navigateTo(...)",@click="cond && navigateTo(...)", one-expression arrow) is already compliant — the expression's promise is implicitly returned into Vue'scallWithAsyncErrorHandling, which is the sanctioned "or return" form. Do not churn these intoasync () => await ....
Route Reads — useRouter().currentRoute, never useRoute()
useRoute() is banned (no-restricted-syntax), pages included. One form everywhere:
const { currentRoute } = useRouter(); // script: currentRoute.value.params.id — template: currentRoute.params.id
Destructured, because a ref reached through router. does not auto-unwrap in a template while currentRoute does.
The message states the fix; what it can't say is why the ban is total rather than "reactive reads only". useRoute() resolves through the page's injected route, which is pinned to that page instance and freezes to its last value once the page is swapped out. Anything outliving the page that created it — a Pinia store above all, cached for the app's lifetime — then answers for a route the user has already left, and a route naming no segment yields the "" sentinel that reaches the server as a uuid and is rejected.
- A segment the page cannot exist without is read through
requireRouteParam(params, name), never anas stringcast: params arestring | string[] | undefined, and a cast hands the empty case to a query that fails at the server instead of here.getRouteParamStringstays for a genuinely optional segment. - Guard before spending a request (
uuidValidateV4(id)) where a read can race a navigation — it resolves the route after the user has left the page that named it, and the lint rule cannot see that. - A
definePageMetavalidate/keycallback receives its ownrouteargument. That is not auseRoute()call and none of the above applies to it. - Tests do not catch the staleness on their own — with no page component in the tree there is no injection to pin, so both forms are the same object there and both pass.
- The one earned exception is a component test that must drive the route.
mockNuxtImport("useRoute")is supported;mockNuxtImport("useRouter")replaces the router Nuxt's own plugins call (router.beforeResolve) and takes the whole environment down. A component that reads the route only to render, holds nothing past its page, and needs that mock takes aneslint-disable-next-linecarrying this reason. - Typed routes do not help and are deliberately off. Both
experimental.typedPagesandnuxt-typed-routertypeparamsas a union across every route, narrowed only by naming the route at the call site — and the generic readers (validate, theuse*FromRoutecomposables) run under several routes, so for them the union is the correct type and no narrowing exists.
Where Navigation State Lives — URL vs History Entry vs Storage
Decide by what the value is, not by what is reachable:
-
Part of what the page shows (filter, page number, tab) → the URL, so a share, a bookmark and a refresh all show the same thing (
useEnumRouteQuerybelow). -
How the visitor got here (a breadcrumb trail, whether this was a drill-down) → the history entry, read back from
window.history.stateand written by merging into it — spread the current state, or the write erases whatever the router keeps there:window.history.replaceState({ ...window.history.state, trail }, "");Its lifetime already matches: per entry, kept across a reload, restored on back/forward, gone with the entry.
Everything in that object is structured-cloned, so none of it may be reactive state. A
ref's array or a store's object reaches the serializer as a Proxy, which it rejects outright — and theDataCloneErroris thrown inside theafterEachhook, so it rejects the navigation that was being recorded rather than merely losing the value. Hand the entry a plain snapshot, and make that the returned contract of the pure function above rather than a spread at the call site. -
What the visitor prefers (a collapsed rail, a theme) →
localStoragethrough theLocalStorageKeyregistry — it outlives the tab and belongs to the person.
The middle case is the one that gets mis-filed. Putting "how I got here" in the URL mints a second address for one page (worse for sharing, bookmarks and analytics, and editable by anyone who types); putting it in storage makes it outlive the journey, so a tab restored later claims a path nobody walked.
Write that state in one place — a router.afterEach hook in a client plugin — never at each link. A value appended by hand at N call sites is one the N+1th link silently drops, and the page that lost it is indistinguishable from a page that never had it. Keep the rules as a pure function so they are testable without a browser, and validate anything read back off an entry (it may predate the release). Worked example: apps/web/content/docs/resource/breadcrumb-trail.md.
Route-Synced Tabs — useEnumRouteQuery
Sync v-tabs state to the URL instead of a plain ref, so the active tab survives a refresh and is linkable. Use useEnumRouteQuery (app/composables/shared/route/useEnumRouteQuery.ts, auto-imported) with the shared TAB_QUERY_PARAMETER_KEY and the enum's value Set.
It validates against the enum via a transform, falling back to the default when the param is missing or invalid — raw @vueuse/router useRouteQuery only falls back when the param is absent, so a hand-edited ?tab=garbage would otherwise leave no tab active. It infers the enum type from its arguments, so no generic is needed.
import { TAB_QUERY_PARAMETER_KEY } from "@/services/route/constants";
import { FooTab, FooTabs } from "@/models/<feature>/FooTab";
// syncs to ?tab=bar and survives refresh — not a plain ref(FooTab.Bar)
const tab = useEnumRouteQuery(TAB_QUERY_PARAMETER_KEY, FooTabs, FooTab.Bar);
Each enum exposes a value Set alongside it (FooTabs) — new Set(Object.values(Enum)), typed ReadonlySet<Enum>. Put useEnumRouteQuery where the v-tabs v-model originates; when a child renders the tabs via defineModel, keep it in the parent and pass it down as v-model.
Optional / Nested Segments — definePageMeta key + validate
For pages with optional or nested route segments sharing one page component (e.g. [id]/[[bar]].vue), each segment change is a different path, so by default the page remounts on every in-page navigation — re-running top-level await loaders and remounting the whole subtree (a shared sidebar/list refetches on every click).
- Key the page by the stable segment only, so sibling-segment navigations reuse the page instead of remounting it.
- Validate params at the route boundary —
definePageMeta({ validate })runs on every navigation, so a bad param 404s before setup. Reuse@/services/router/validate(uuid v4 forid).
// pages/foos/[id]/[[bar]].vue
import { validate } from "@/services/router/validate";
import { getRouteParamString } from "@/util/router/getRouteParamString";
import { requireRouteParam } from "@/util/router/requireRouteParam";
definePageMeta({
key: (route) => `foo-${Array.isArray(route.params.id) ? route.params.id[0] : route.params.id}`,
middleware: "auth",
validate: (route) => validate(route) && (!route.params.bar || typeof route.params.bar === "string"),
});
const { currentRoute } = useRouter();
// Keyed/stable segment → read once (the page remounts on id change), through the throwing helper
const id = requireRouteParam(currentRoute.value.params, "id");
const { foo, load } = useFoo(id);
await load();
// Only the CHANGING segment needs a computed — it updates without a remount once the page is reused
const activeBar = computed(() => getRouteParamString(currentRoute.value.params.bar) || FooBarType.Default);
Validate only what is knowable before load. validate runs before setup, so it cannot see fetched data — it checks shape (uuid, typeof x === "string", an enum Set). A segment whose valid values depend on loaded data must be guarded after the load instead. Because sibling-segment switches reuse the page instance, that guard is a watchImmediate (a one-shot setup check would not re-run on reuse), not a setup-time if:
// per-type bar slugs need the loaded foo's type, so validate can't cover them
watchImmediate([activeBar, foo], ([newActiveBar, newFoo]) => {
if (newFoo && !isValidFooBar(newFoo.type, newActiveBar))
showError(createError({ statusCode: 404, statusMessage: "Foo bar not found" }));
});
Rules:
- A
v-list-item@click="navigateTo(...)"/ a<NuxtLink to>are already SPA navigations — they do not cause (or fix) a remount refetch. The remount comes from the per-segment page key, so fix it at the page level. - The keyed/stable segment is read once through
requireRouteParam— the page remounts when it changes, so a capturedconststays correct. Only the changing segment needs acomputed, since a capturedconstfor it goes stale once the page is reused. takeOne(@esposter/shared) is thenoUncheckedIndexedAccessworkaround for array / first-element access — not forstring | string[]route params, whichrequireRouteParam/getRouteParamStringalready normalize.
Signals
- GitHub stars
- 23
- Forks
- 3
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
routing- Source
- github.com/esposter/esposter